Skip to content

Add Merge Entries action to combine duplicate entries - #13661

Open
darknight7 wants to merge 1 commit into
keepassxreboot:developfrom
darknight7:feature/merge-entries
Open

darknight7 wants to merge 1 commit into
keepassxreboot:developfrom
darknight7:feature/merge-entries

Conversation

@darknight7

Copy link
Copy Markdown

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:

  • pick the entry to merge into (the most recently modified entry is preselected);
  • choose, for every field the entries disagree on, the value to keep;
  • combine the notes of all entries instead of choosing one (checked by default);
  • keep the values that lose a conflict as custom attributes (checked by default);
  • delete the merged entries afterwards (checked by default).

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.

  • The values chosen in the dialog are applied first. Everything else (attributes, attachments, tags, Auto-Type associations, custom data, icon, TOTP settings) is taken from the other entries only where the target has nothing of its own.
  • URLs that lose a conflict become additional URLs (KP2A_URL, KP2A_URL_1, …, same naming as EditEntryWidget::insertURL()). Other values that lose a conflict become custom attributes (Title_1, Notes_1, …) when that option is enabled.
  • An attachment whose name collides with a different file is kept under a suffixed name that preserves the extension (notes.txtnotes_1.txt).
  • Protection is never downgraded: an attribute stays protected if any of the merged entries protected it.
  • Protected values are never displayed in the dialog. For protected fields the choice is offered by source entry ("Value of entry").
  • A passkey is treated as one unit and only moves into an entry that has none. An entry whose passkey would have to be dropped is left out of the merge entirely (neither merged nor deleted) and the dialog names it. This avoids keeping a half-merged entry around just for its passkey. Changing the target recomputes which entries are left out and which conflicts are shown.
  • The merge is wrapped in beginUpdate()/endUpdate(), so the previous state of the target can be restored from its History tab. The merged entries themselves are not modified.
  • Merged entries are removed through 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() and Entry::mergeFrom() with Entry::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 keep Entry lean.
  • src/gui/entry/MergeEntriesDialog.{h,cpp,ui}: new dialog, named to avoid confusion with the existing database MergeDialog.
  • src/gui/DatabaseWidget.{h,cpp}: new mergeSelectedEntries() slot.
  • src/gui/MainWindow.{cpp,ui}: actionEntryMerge in the Entries menu, the entry context menu, the action collection and the menu state handling. It reuses the database-merge icon, which is a generic merge glyph.
  • src/CMakeLists.txt: new source file.
  • tests/TestEntry.{h,cpp}: new tests, listed below.

src/format, src/crypto and the database merge (Merger) are untouched.

Open questions

  1. Where discarded values go. 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.
  2. Order of passkey exclusion. If the target has no passkey, the first entry with a passkey (in selection order) is merged and any later one is left out. The dialog always names the entries that are left out.
  3. Cancelling the permanent-deletion confirmation. The confirmation comes after the merge has been applied. Cancelling it leaves the target merged and the other entries in place: data is duplicated but nothing is lost, and the merge can be undone from History. Asking before merging would mean duplicating the permanence check of deleteEntries() in the dialog.
  4. References. {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 in GuiTools::deleteEntriesResolveReferences() applies.
  5. Preselected target. The most recently modified entry is preselected. On equal timestamps the first selected entry wins.

Known gaps

  • TOTP was not tested. TOTP settings are stored as attributes (otp, or TOTP Seed plus TOTP Settings), so they follow the generic attribute rules: a conflicting otp value is offered by source entry and, if discarded, kept as otp_1, which preserves the secret but is not an active TOTP. Merging an entry that uses otp with one that uses the legacy TOTP Seed/TOTP Settings pair would copy both representations into the target. I have not checked which one takes effect.
  • Translations. release-tool.py i18n lupdate was not run: its check only accepts lupdate 5.x, while the project builds against Qt 6 (lupdate 6.10.2 on my machine). The new strings still need to be extracted.
  • Platforms. Built and tested on Linux only. Not built on Windows or macOS.
  • Cosmetic. The permanent-deletion confirmation opens while the merge dialog is still visible behind it.

Screenshots

All data shown is made up.

Context menu with two entries selected:
1-context-menu

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

Entries that do not disagree on any field:
3-merge-dialog-no-conflicts

An entry whose passkey cannot be merged is left out and named:
4-merge-dialog-passkey-left-out

Permanent deletion (recycle bin disabled) asks for confirmation:
5-permanent-deletion-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 without MergeKeepDiscardedValues is 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 .kdbx was inspected with keepassxc-cli and by comparing XML exports taken before and after the merge:

  1. The duplicate pair from the issue (an entry without URL plus the entry created by the browser extension): conflicts table, password offered by source entry, notes combined, URL filled in, discarded title kept as Title_1.
  2. Three entries for the same account with different URLs: the discarded URLs end up as KP2A_URL and KP2A_URL_1.
  3. Two entries that do not disagree on any field: compact dialog without the conflicts table.
  4. An entry with a passkey merged into an entry with a password and an attachment of the same name: the passkey attributes are identical to the source, protection flags included, recovery_1.txt is byte-identical to the source attachment (cmp), and the target's own attachment is unchanged.
  5. Three entries, two of them with a passkey, recycle bin disabled: the dialog names the entry that is left out and updates when the target changes. After the merge that entry is identical to before (attributes, tags, modification time). The confirmation names only the entry that was actually merged.
  6. History tab of the target: the pre-merge version is listed and can be restored.

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. The format target was run. Note that clang-format 21.1.8 also reformats seven files unrelated to this change; those changes are not included.

Full test suite: 42 of 43 tests pass. testcli fails only in TestCli::testClip with "All clipping programs failed", because the build machine has no display and no clipboard program.

Type of change

  • ✅ New feature (change that adds functionality)

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

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>
@varjolintu varjolintu added the pr: ai-assisted Pull request contains significant contributions by generative AI label Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr: ai-assisted Pull request contains significant contributions by generative AI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Right Click -> Merge selected entries

2 participants