feat(bib,zot): deterministic URL lookup + nightly library-wide merge-dupes (refs #665)
find_item_by_url now ORDER BY i.itemID so bib.sync consistently keeps updating the same "earliest" item when duplicates exist — the one zot.merge treats as the merge keeper. Wires --merge-dupes/--no-merge-dupes (default off) into `stack bib sync-zotero`, running merge_url_duplicates library-wide after the push, inside the same stop window. --dedupe-collection stays but is documented as deprecated (deletes without merging). Regenerates .gitea/workflows/zotero-sync.yml (source: dev/scripts/backends/gitea.py) to swap --dedupe-collection Cmsupdates for --merge-dupes, since listserv re-sends are now aliased at ingest (Task 3) and any same-URL twins are merged, not deleted.
This commit is contained in:
@@ -24,14 +24,14 @@ jobs:
|
|||||||
run: docker stop zotero
|
run: docker stop zotero
|
||||||
|
|
||||||
- name: Sync mail-ingested bib items into Zotero
|
- name: Sync mail-ingested bib items into Zotero
|
||||||
# --dedupe-collection: CMS listservs re-send identical mail with
|
# --merge-dupes: listserv re-sends are now aliased at ingest
|
||||||
# fresh Message-IDs; the ingest correctly stores each send, so
|
# (Task 3), and any same-URL twins that still land in Zotero are
|
||||||
# title+date duplicates accumulate unless swept here (keeps the
|
# merged (union tags/collections/notes/attachments, keep-earliest)
|
||||||
# earliest copy, removes later ones from Zotero AND bib).
|
# rather than deleted.
|
||||||
run: |
|
run: |
|
||||||
docker exec api uv run --no-sync \
|
docker exec api uv run --no-sync \
|
||||||
stack bib sync-zotero --tag source:email --no-hold \
|
stack bib sync-zotero --tag source:email --no-hold \
|
||||||
--dedupe-collection Cmsupdates
|
--merge-dupes
|
||||||
|
|
||||||
- name: Sync Federal Register rule items into Zotero
|
- name: Sync Federal Register rule items into Zotero
|
||||||
# Rule items only ever reached Zotero via one-off manual syncs
|
# Rule items only ever reached Zotero via one-off manual syncs
|
||||||
|
|||||||
@@ -616,14 +616,14 @@ jobs:
|
|||||||
run: docker stop zotero
|
run: docker stop zotero
|
||||||
|
|
||||||
- name: Sync mail-ingested bib items into Zotero
|
- name: Sync mail-ingested bib items into Zotero
|
||||||
# --dedupe-collection: CMS listservs re-send identical mail with
|
# --merge-dupes: listserv re-sends are now aliased at ingest
|
||||||
# fresh Message-IDs; the ingest correctly stores each send, so
|
# (Task 3), and any same-URL twins that still land in Zotero are
|
||||||
# title+date duplicates accumulate unless swept here (keeps the
|
# merged (union tags/collections/notes/attachments, keep-earliest)
|
||||||
# earliest copy, removes later ones from Zotero AND bib).
|
# rather than deleted.
|
||||||
run: |
|
run: |
|
||||||
docker exec api uv run --no-sync \\
|
docker exec api uv run --no-sync \\
|
||||||
stack bib sync-zotero --tag source:email --no-hold \\
|
stack bib sync-zotero --tag source:email --no-hold \\
|
||||||
--dedupe-collection Cmsupdates
|
--merge-dupes
|
||||||
|
|
||||||
- name: Sync Federal Register rule items into Zotero
|
- name: Sync Federal Register rule items into Zotero
|
||||||
# Rule items only ever reached Zotero via one-off manual syncs
|
# Rule items only ever reached Zotero via one-off manual syncs
|
||||||
|
|||||||
@@ -506,10 +506,19 @@ def sync_zotero(
|
|||||||
dedupe_collection: str = typer.Option(
|
dedupe_collection: str = typer.Option(
|
||||||
"",
|
"",
|
||||||
"--dedupe-collection",
|
"--dedupe-collection",
|
||||||
help="After the push, remove title+date duplicates from this Zotero "
|
help="DEPRECATED — use --merge-dupes instead, which merges rather "
|
||||||
"collection (and from bib, so the next sync can't resurrect them). "
|
"than deletes. After the push, remove title+date duplicates from "
|
||||||
"CMS listservs re-send identical mail with fresh Message-IDs, so "
|
"this Zotero collection (and from bib, so the next sync can't "
|
||||||
"duplicates otherwise accumulate — e.g. 'Cmsupdates'.",
|
"resurrect them). CMS listservs re-send identical mail with fresh "
|
||||||
|
"Message-IDs, so duplicates otherwise accumulate — e.g. "
|
||||||
|
"'Cmsupdates'.",
|
||||||
|
),
|
||||||
|
merge_dupes: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--merge-dupes/--no-merge-dupes",
|
||||||
|
help="After the push, merge same-URL duplicate items library-wide "
|
||||||
|
"(union tags/collections/notes/attachments; keep-earliest). "
|
||||||
|
"Supersedes --dedupe-collection, which deletes without merging.",
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Push bib items (and their attachments) to the Zotero database.
|
"""Push bib items (and their attachments) to the Zotero database.
|
||||||
@@ -523,9 +532,11 @@ def sync_zotero(
|
|||||||
from bib import connect
|
from bib import connect
|
||||||
from bib.sync import dedupe_collection as run_dedupe
|
from bib.sync import dedupe_collection as run_dedupe
|
||||||
from bib.sync import push_to_zotero
|
from bib.sync import push_to_zotero
|
||||||
|
from conf import path
|
||||||
|
|
||||||
store = connect()
|
store = connect()
|
||||||
items = store.list_items(tag=tag, item_type=item_type)
|
items = store.list_items(tag=tag, item_type=item_type)
|
||||||
|
zotero_db_path = str(path("db.zotero"))
|
||||||
|
|
||||||
def _docker(*args: str) -> None:
|
def _docker(*args: str) -> None:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -538,9 +549,16 @@ def sync_zotero(
|
|||||||
try:
|
try:
|
||||||
if hold_zotero:
|
if hold_zotero:
|
||||||
_docker("stop", "zotero")
|
_docker("stop", "zotero")
|
||||||
stats = push_to_zotero(items, store=store)
|
stats = push_to_zotero(items, store=store, zotero_db=zotero_db_path)
|
||||||
if dedupe_collection:
|
if dedupe_collection:
|
||||||
stats |= run_dedupe(dedupe_collection, store=store)
|
stats |= run_dedupe(dedupe_collection, store=store)
|
||||||
|
if merge_dupes:
|
||||||
|
from zot.merge import merge_url_duplicates
|
||||||
|
|
||||||
|
stats |= {
|
||||||
|
f"merge_{k}": v
|
||||||
|
for k, v in merge_url_duplicates(zotero_db_path, dry_run=False).items()
|
||||||
|
}
|
||||||
finally:
|
finally:
|
||||||
if hold_zotero:
|
if hold_zotero:
|
||||||
_docker("start", "zotero")
|
_docker("start", "zotero")
|
||||||
|
|||||||
@@ -389,12 +389,19 @@ class Db:
|
|||||||
return row[0] if row else None
|
return row[0] if row else None
|
||||||
|
|
||||||
def find_item_by_url(self, url: str) -> int | None:
|
def find_item_by_url(self, url: str) -> int | None:
|
||||||
"""Return ``itemID`` of the first item whose ``url`` field matches."""
|
"""Return the lowest ``itemID`` of items whose ``url`` field matches.
|
||||||
|
|
||||||
|
Deterministic (``ORDER BY i.itemID``) so that when duplicates
|
||||||
|
exist, callers (e.g. ``bib.sync``) consistently keep updating
|
||||||
|
the same "earliest" item — the one ``zot.merge`` also treats
|
||||||
|
as the merge keeper.
|
||||||
|
"""
|
||||||
row = self.con.execute(
|
row = self.con.execute(
|
||||||
"""SELECT i.itemID FROM items i
|
"""SELECT i.itemID FROM items i
|
||||||
JOIN itemData id ON i.itemID = id.itemID
|
JOIN itemData id ON i.itemID = id.itemID
|
||||||
JOIN itemDataValues idv ON id.valueID = idv.valueID
|
JOIN itemDataValues idv ON id.valueID = idv.valueID
|
||||||
WHERE id.fieldID = ? AND idv.value = ?""",
|
WHERE id.fieldID = ? AND idv.value = ?
|
||||||
|
ORDER BY i.itemID""",
|
||||||
(FIELD_MAP["url"], url),
|
(FIELD_MAP["url"], url),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return row[0] if row else None
|
return row[0] if row else None
|
||||||
|
|||||||
@@ -836,3 +836,21 @@ class TestValidation:
|
|||||||
# Should have at least author
|
# Should have at least author
|
||||||
assert len(result) >= 1
|
assert len(result) >= 1
|
||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindItemByUrlDeterministic:
|
||||||
|
def test_returns_lowest_item_id(self, tmp_path):
|
||||||
|
from zot.db import TYPE_MAP, Db
|
||||||
|
from zot.schema import create_db
|
||||||
|
|
||||||
|
p = tmp_path / "z.sqlite"
|
||||||
|
create_db(str(p)).close()
|
||||||
|
with Db(str(p)) as db:
|
||||||
|
a = db.create_item(TYPE_MAP["document"], key="AAAAAAAA")
|
||||||
|
b = db.create_item(TYPE_MAP["document"], key="BBBBBBBB")
|
||||||
|
# insert the URL row for the HIGHER itemID first so a
|
||||||
|
# scan-order query would return it
|
||||||
|
db.set_field(b, "url", "https://example.org/det")
|
||||||
|
db.set_field(a, "url", "https://example.org/det")
|
||||||
|
db.commit()
|
||||||
|
assert db.find_item_by_url("https://example.org/det") == a
|
||||||
|
|||||||
Reference in New Issue
Block a user