feat(bib/sync): push bib notes into Zotero itemNotes

Mirrors _sync_attachments. Idempotent dedup keyed on
(parentItemID, title) so re-syncing is a no-op. Wired into both
the create-item and update-existing-item branches of push_to_zotero.
Adds 'notes' counter to the stats dict.

Fixes test_sync_deeper mocks: both _sync_attachments and _sync_notes
call store._con().execute(); updated the MagicMock side_effect to
route by SQL fragment so the notes query returns [] while attachment
queries return the fixture rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kert
2026-04-29 09:37:12 -04:00
parent 0012b2b4c7
commit e204ea0e4c
3 changed files with 161 additions and 4 deletions

View File

@@ -261,6 +261,7 @@ def push_to_zotero(
"tags": 0, "tags": 0,
"collections": 0, "collections": 0,
"attachments": 0, "attachments": 0,
"notes": 0,
} }
# Resolve a tuple-path to a Zotero collection key, ensuring each # Resolve a tuple-path to a Zotero collection key, ensuring each
@@ -305,6 +306,12 @@ def push_to_zotero(
existing_id, existing_id,
storage_dir, storage_dir,
) )
stats["notes"] += _sync_notes(
db,
store,
item,
existing_id,
)
# Backfill collection membership for items that were # Backfill collection membership for items that were
# created before sync learned about the hierarchy. # created before sync learned about the hierarchy.
path = _zotero_collection_path(item) path = _zotero_collection_path(item)
@@ -371,6 +378,12 @@ def push_to_zotero(
item_id, item_id,
storage_dir, storage_dir,
) )
stats["notes"] += _sync_notes(
db,
store,
item,
item_id,
)
stats["created"] += 1 stats["created"] += 1
@@ -489,3 +502,44 @@ def _sync_attachments(
) )
count += 1 count += 1
return count return count
def _sync_notes(
db: Db,
store: Store,
bib_item: Item,
zot_parent_id: int,
) -> int:
"""Push every bib note for ``bib_item`` into Zotero's ``itemNotes``.
Idempotent on (parentItemID, title): an existing same-title child
note is left alone. Notes are emitted as child items of
``zot_parent_id``."""
con = store._con() # noqa: SLF001
rows = con.execute(
"""SELECT n.title, n.content
FROM notes n
JOIN items i ON n.item_id = i.id
WHERE i.key = ?""",
(bib_item.key,),
).fetchall()
if not rows:
return 0
existing_titles = {
r[0]
for r in db.con.execute(
"SELECT title FROM itemNotes WHERE parentItemID = ?",
(zot_parent_id,),
).fetchall()
}
count = 0
for row in rows:
title = row["title"] or ""
if title in existing_titles:
continue
db.add_note(zot_parent_id, row["content"], title=title)
existing_titles.add(title)
count += 1
return count

View File

@@ -100,6 +100,25 @@ CREATE TABLE IF NOT EXISTS creatorTypes (
creatorTypeID INTEGER PRIMARY KEY, creatorTypeID INTEGER PRIMARY KEY,
creatorType TEXT creatorType TEXT
); );
CREATE TABLE IF NOT EXISTS itemNotes (
itemID INTEGER PRIMARY KEY,
parentItemID INT,
note TEXT,
title TEXT
);
CREATE TABLE IF NOT EXISTS itemAttachments (
itemID INTEGER PRIMARY KEY,
parentItemID INT,
linkMode INT,
contentType TEXT,
charsetID INT,
path TEXT,
syncState INT DEFAULT 0,
storageModTime INT,
storageHash TEXT
);
""" """
@@ -394,3 +413,71 @@ class TestPushToZotero:
] ]
stats = push_to_zotero(items, zotero_db=db_path, collection_key="MKEY2345") stats = push_to_zotero(items, zotero_db=db_path, collection_key="MKEY2345")
assert stats["collections"] == 1 assert stats["collections"] == 1
class TestSyncNotes:
"""bib notes → Zotero itemNotes (non-attachment children)."""
def _setup_bib_with_note(self, tmp_path, *, html: str, title: str):
from bib import connect
from bib.item import Source
bib_db = tmp_path / "bib.sqlite"
store = connect(str(bib_db))
item = Source(
title="Comment CMS-2024-0001-0001",
url="https://www.regulations.gov/comment/CMS-2024-0001-0001",
)
item_key = store.upsert(item)
store.attach_note(item_key, html, title=title)
store.close()
return bib_db, item_key
def test_note_creates_zotero_itemnote(self, tmp_path):
bib_db, item_key = self._setup_bib_with_note(
tmp_path, html="<h1>Body</h1><p>text</p>", title="Comment text"
)
zot_db = str(tmp_path / "zotero.sqlite")
con = _make_zotero_db(zot_db)
con.close()
from bib import connect
store = connect(str(bib_db))
items = store.list_items(tag=None)
stats = push_to_zotero(items, store=store, zotero_db=zot_db)
store.close()
assert stats["notes"] == 1
con = sqlite3.connect(zot_db)
rows = con.execute("SELECT title, note FROM itemNotes").fetchall()
con.close()
assert rows == [("Comment text", "<h1>Body</h1><p>text</p>")]
def test_resync_is_idempotent(self, tmp_path):
"""Second sync must not duplicate the note (dedup by parent + title)."""
bib_db, item_key = self._setup_bib_with_note(
tmp_path, html="<p>x</p>", title="Comment text"
)
zot_db = str(tmp_path / "zotero.sqlite")
con = _make_zotero_db(zot_db)
con.close()
from bib import connect
store = connect(str(bib_db))
items = store.list_items(tag=None)
push_to_zotero(items, store=store, zotero_db=zot_db)
stats = push_to_zotero(items, store=store, zotero_db=zot_db)
store.close()
assert stats["notes"] == 0 # second pass, nothing new
con = sqlite3.connect(zot_db)
n = con.execute("SELECT COUNT(*) FROM itemNotes").fetchone()[0]
con.close()
assert n == 1

View File

@@ -147,8 +147,7 @@ class TestPushToZotero:
src.parent.mkdir() src.parent.mkdir()
src.write_bytes(b"%PDF content") src.write_bytes(b"%PDF content")
store = MagicMock() att_rows = [
store._con.return_value.execute.return_value.fetchall.return_value = [
{ {
"filename": "paper.pdf", "filename": "paper.pdf",
"content_type": "application/pdf", "content_type": "application/pdf",
@@ -156,6 +155,15 @@ class TestPushToZotero:
}, },
] ]
def _execute(sql, *args, **kwargs):
m = MagicMock()
# Notes query selects n.title; attachment query selects a.filename
m.fetchall.return_value = att_rows if "a.filename" in sql else []
return m
store = MagicMock()
store._con.return_value.execute.side_effect = _execute
item = Source(title="Test", url="https://unique-url-test.com/doc") item = Source(title="Test", url="https://unique-url-test.com/doc")
item.doc_type = "Public Comment" item.doc_type = "Public Comment"
@@ -223,8 +231,7 @@ class TestPushToZoteroExistingWithAttachments:
src.parent.mkdir() src.parent.mkdir()
src.write_bytes(b"%PDF content") src.write_bytes(b"%PDF content")
store = MagicMock() att_rows = [
store._con.return_value.execute.return_value.fetchall.return_value = [
{ {
"filename": "paper.pdf", "filename": "paper.pdf",
"content_type": "application/pdf", "content_type": "application/pdf",
@@ -232,6 +239,15 @@ class TestPushToZoteroExistingWithAttachments:
}, },
] ]
def _execute(sql, *args, **kwargs):
m = MagicMock()
# Notes query selects n.title; attachment query selects a.filename
m.fetchall.return_value = att_rows if "a.filename" in sql else []
return m
store = MagicMock()
store._con.return_value.execute.side_effect = _execute
item = Manual( item = Manual(
title="IOM Ch1", title="IOM Ch1",
url="https://example.com/iom-ch1", url="https://example.com/iom-ch1",