Add Merge Entries action to combine duplicate entries - #13661
Open
darknight7 wants to merge 1 commit into
Open
darknight7 wants to merge 1 commit into
darknight7 wants to merge 1 commit into
Conversation
Select two or more entries and choose Merge Entries to combine them into one. The user picks the entry to keep and, for every field the entries disagree on, the value to keep. Nothing is dropped silently: losing URLs become additional URLs, other losing values can be kept as custom attributes, colliding attachments are renamed, and protection is never downgraded. A passkey is merged as a whole and only into an entry without one. An entry whose passkey would have to be dropped is left out of the merge. The merge is recorded in the entry history, and permanently deleting the merged entries asks for confirmation. Fixes keepassxreboot#2202 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a Merge Entries… action that combines two or more selected entries into one.
Fixes #2202
Duplicate entries are common: an entry saved by KeePassXC-Browser next to an existing entry that had no URL (the case described in the issue), the same account reached through different URLs, or a passkey registered into a new entry next to the existing password entry. Until now the only way to clean them up was to copy fields over by hand.
How it works
Select two or more entries, then choose Merge Entries… from the context menu or the Entries menu. The dialog lets the user:
The action is enabled when at least two entries are selected outside the recycle bin. It is registered in the action collection, so its shortcut can be customized.
Merge rules
The goal is that a merge never silently replaces or drops data.
KP2A_URL,KP2A_URL_1, …, same naming asEditEntryWidget::insertURL()). Other values that lose a conflict become custom attributes (Title_1,Notes_1, …) when that option is enabled.notes.txt→notes_1.txt).beginUpdate()/endUpdate(), so the previous state of the target can be restored from its History tab. The merged entries themselves are not modified.DatabaseWidget::deleteEntries(). With the recycle bin enabled they are moved there without an extra prompt, since the dialog checkbox already covers that. When the deletion is permanent, the standard confirmation is shown.Implementation
src/core/Entry.{h,cpp}:Entry::conflictingAttributes(),Entry::unmergeableEntries()andEntry::mergeFrom()withEntry::MergeFlags. The logic lives in the core so that it can be unit tested without the GUI. I am happy to move it into a separate class if you prefer to keepEntrylean.src/gui/entry/MergeEntriesDialog.{h,cpp,ui}: new dialog, named to avoid confusion with the existing databaseMergeDialog.src/gui/DatabaseWidget.{h,cpp}: newmergeSelectedEntries()slot.src/gui/MainWindow.{cpp,ui}:actionEntryMergein the Entries menu, the entry context menu, the action collection and the menu state handling. It reuses thedatabase-mergeicon, which is a generic merge glyph.src/CMakeLists.txt: new source file.tests/TestEntry.{h,cpp}: new tests, listed below.src/format,src/cryptoand the database merge (Merger) are untouched.Open questions
KP2A_URL*for URLs,<Field>_<n>custom attributes for everything else. When browser integration is disabled the Browser Integration tab is hidden, so additional URLs only show up under Advanced → Attributes.deleteEntries()in the dialog.{REF:…}placeholders pointing to a merged entry are not redirected to the target. With the recycle bin they keep resolving to the recycled entry. On permanent deletion, the existing prompt inGuiTools::deleteEntriesResolveReferences()applies.Known gaps
otp, orTOTP SeedplusTOTP Settings), so they follow the generic attribute rules: a conflictingotpvalue is offered by source entry and, if discarded, kept asotp_1, which preserves the secret but is not an active TOTP. Merging an entry that usesotpwith one that uses the legacyTOTP Seed/TOTP Settingspair would copy both representations into the target. I have not checked which one takes effect.release-tool.py i18n lupdatewas not run: its check only acceptslupdate5.x, while the project builds against Qt 6 (lupdate6.10.2 on my machine). The new strings still need to be extracted.Screenshots
All data shown is made up.
Context menu with two entries selected:

Merge dialog with conflicts (the password is offered by source entry, not shown):

Entries that do not disagree on any field:

An entry whose passkey cannot be merged is left out and named:

Permanent deletion (recycle bin disabled) asks for confirmation:

Testing strategy
Unit tests in
tests/TestEntry.cpp:testConflictingAttributes: only fields holding differing non-empty values are reported (not those the entries agree on, nor those only one entry holds), and a single entry never conflicts with itself.testMergeFrom: missing values are filled in, the losing URL becomes an additional URL, tags and Auto-Type associations are combined, the icon is inherited by a target with the default icon, a colliding attachment is renamed with its extension preserved, and the source entry is left untouched.testMergeFromResolvesConflicts: chosen values are applied, the replaced URL is kept as an additional URL, protection is inherited from the source, and the behaviour with and withoutMergeKeepDiscardedValuesis checked.testMergeFromKeepsPasskeyIntact: a passkey is carried over completely, protection flags and tag included. A second passkey never overwrites the first, and its entry contributes nothing else to the merge.testUnmergeableEntries: with three entries, the first passkey is merged, a later one is left out, and nothing from the entry that is left out reaches the target.testMergeFromConcatenatesNotes: notes are concatenated without duplicates, kept as they are without the flag, and there is no leading separator when the target has no notes.Manual testing in the GUI with throwaway databases. After each merge the resulting
.kdbxwas inspected withkeepassxc-cliand by comparing XML exports taken before and after the merge:Title_1.KP2A_URLandKP2A_URL_1.recovery_1.txtis byte-identical to the source attachment (cmp), and the target's own attachment is unchanged.Environment: Ubuntu 26.04, GCC 15.2, Qt 6.10.2, Botan 3.10, CMake 4.2.3, Debug build with
-DWITH_XC_ALL=ON. No compiler warnings. Theformattarget was run. Note thatclang-format21.1.8 also reformats seven files unrelated to this change; those changes are not included.Full test suite: 42 of 43 tests pass.
testclifails only inTestCli::testClipwith "All clipping programs failed", because the build machine has no display and no clipboard program.Type of change
Use of generative AI
As required by
CONTRIBUTING.md: most of this code was written with Claude Opus 5 (Anthropic) through Claude Code. I directed the work, made the design decisions (including leaving an entry out of the merge entirely when its passkey cannot be merged, combining notes, and the dialog wording), tested the feature hands-on, and asked in advance whether an AI-assisted pull request would be welcome.🤖 Generated with Claude Code