zotero: rid the library of duplicates — merge-based dedupe + close the three ingest/ops holes #665
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom: the Zotero library holds ~9,000 surplus duplicate items (plus a handful of upstream bib duplicates). Full evidence + implementation plan:
docs/superpowers/plans/2026-08-26-zotero-dedupe.md.Root causes (measured 2026-08-26 on a DB snapshot)
Class A — 2026-05-14 key-matched restore (~8,981 surplus items in 8,955 same-URL clusters).
Timeline:
pre-rekey.baktaken 05-13 16:03 → commit8149da5(05-13 16:40) droppedLfrom the Zotero key charset and live rows were rekeyed in place → on 05-14 13:41–13:46 an uncommitted one-off restored rows from the pre-rekey backup matching by key: every rekeyed row looked "missing" and was re-inserted under its old L-key with original dateAdded (8,938/8,981 twins carry anL; itemIDs ~371k–376k; 22,734 child rows came along).bib.syncmatches by URL and updates only the first-found copy, so twins rot (avg 5.1 tags vs 15.5) — but ~23% of sampled clusters have a twin holding MORE child notes than the keeper, so blind deletion loses data.Class B — listserv re-sends (bib-level, 17 title clusters / 34 surplus).
Mail ingest identity is
email:<Message-ID>; CMS re-sends identical content under fresh Message-IDs on different days. The nightly--dedupe-collection Cmsupdatespass groups on (title, date), so cross-day re-sends never group — and it deletes without merging.Class C — pre-#624 ingest URL dups in bib (3 clusters, ~17 surplus rows, e.g. aco-reach ×12), mirrored into Zotero.
Class D — 2,080 same-DOI clusters with zero PMID collisions = PubMed's own dual records + Class A twins; residue after A/C is a human triage call, not an auto-merge.
Not duplicates (protected): ~11k Zotero-only personal/archive items (audio, dictionary entries, artwork…); same-title different-PMID records (Cochrane updates); page-range junk in
urlfields.Fix (per the plan)
zot.mergeengine: fidelity-preserving merge (union tags/collections/relations, adopt missing notes/attachments, keep-earliest, dry-run + JSON merge logs, skip type-mismatched clusters).find_item_by_urlgetsORDER BY itemID; nightly zotero-sync runs library-wide--merge-dupesinstead of the lossy title+date collection pass.extra_json.alias_midsinstead of creating new items.Prevention note: the 05-14 one-off was never committed; the guard is structural — restores/merges must match by URL (not key), and any bulk op ends with the dry-run cluster census.
Executed and verified 2026-08-26. Full record:
docs/superpowers/plans/2026-08-26-zotero-dedupe.md(Outcome section) and.state/zotero-dedupe/.Live run (zotero stop window ~15:14–15:29 EDT, backups
*.pre-dedupe-20260826.bak):stage:tags unchanged; FK check clean; the sample PMIDs now unique.Prevention shipped:
zot.mergeengine (merge, never blind-delete; pre-delete decision log); deterministicfind_item_by_url; nightly--merge-dupesreplaces the lossy title+date collection pass (logs land host-durable indata/zotero/data/dedupe-logs/); mail ingest aliases re-sends by body hash (with empty-body guard + self-exclusion);body_sha1backfilled on 217 legacy email keepers.Accepted residuals: one intentional type-mismatch cluster (GTVEBJ3T document / 76PA8PRE webpage @ qpp.cms.gov resource-library — shows as the nightly's standing
skipped_type_mismatch: 1); 185 legacy email items with truncated abstracts can each spawn at most one new item on their next re-send; 3 same-DOI clusters (1 genuine PubMed dual record, 2 DOI-field noise) left for human triage in.state/zotero-dedupe/doi-clusters.md.Rollback: restore the three
.bakfiles inside a zotero-stop window.