Overview
Background scan on library load groups entries by identical DOI (exact match) or same normalised title + year (fuzzy match). A 'Duplicates' badge appears in the sidebar. A side-by-side merge panel lets the user pick the canonical value for each field, combining collections and tags from both entries.
Scope
Rust (src-tauri/src/library.rs)
- Add ind_duplicates() -> Vec<Vec> command
- Exact DOI duplicates: SELECT doi, GROUP_CONCAT(id) FROM items WHERE doi != '' GROUP BY LOWER(TRIM(doi)) HAVING COUNT(*) > 1
- Fuzzy title+year: normalise title (lowercase, strip punctuation, collapse spaces), group by (normalised_title, year) with COUNT(*) > 1
- Return groups as Vec<Vec> (each inner vec is a group of item IDs that are duplicates of each other)
- Add merge_items(keep_id: i64, discard_ids: Vec, merged_item: ItemInput) -> LibraryItem command
- Update the kept item with merged_item fields
- Move all collection memberships and tags from discarded items to kept item (INSERT OR IGNORE)
- Soft-delete the discarded items (insert into trash)
Frontend (src/composables/useLibrary.ts)
- Add indDuplicates() -> number[][] and mergeItems(keepId, discardIds, mergedItem) functions
Frontend (src/components/CollectionsSidebar.vue)
- Add a Duplicates row with badge count (hidden when 0)
- Clicking it sets showingDuplicates flag on parent
Frontend (src/components/DuplicateMergePanel.vue) — new file
- Receives two LibraryItem objects (one group at a time)
- For each field: show value from item A and item B side by side, radio to pick which to keep (or edit manually)
- 'Merge' button calls mergeItems; 'Skip' moves to next duplicate pair
- Progress indicator: 'X of Y duplicate groups'
Frontend (src/views/LibraryView.vue)
- When showingDuplicates: show list of duplicate groups, clicking a group opens DuplicateMergePanel
- Run indDuplicates() after loadItems() to populate the badge count
Notes
- Title normalisation for fuzzy match: lowercase, remove punctuation ([^a-z0-9 ]), collapse whitespace, trim. Two titles are a match if normalised strings are equal AND year matches.
- Merge only moves data to the kept item — never silently deletes anything. Discarded items go to Trash so they are recoverable.
Verification
- Import a .bib that has a duplicate of an existing entry (same DOI)
- See 'Duplicates (1)' badge in sidebar
- Open merge panel, verify both entries shown side by side
- Merge → kept entry has combined tags/collections, discarded entry moves to Trash
- Badge clears after all groups resolved
Overview
Background scan on library load groups entries by identical DOI (exact match) or same normalised title + year (fuzzy match). A 'Duplicates' badge appears in the sidebar. A side-by-side merge panel lets the user pick the canonical value for each field, combining collections and tags from both entries.
Scope
Rust (src-tauri/src/library.rs)
Frontend (src/composables/useLibrary.ts)
Frontend (src/components/CollectionsSidebar.vue)
Frontend (src/components/DuplicateMergePanel.vue) — new file
Frontend (src/views/LibraryView.vue)
Notes
Verification