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:
@@ -261,6 +261,7 @@ def push_to_zotero(
|
||||
"tags": 0,
|
||||
"collections": 0,
|
||||
"attachments": 0,
|
||||
"notes": 0,
|
||||
}
|
||||
|
||||
# Resolve a tuple-path to a Zotero collection key, ensuring each
|
||||
@@ -305,6 +306,12 @@ def push_to_zotero(
|
||||
existing_id,
|
||||
storage_dir,
|
||||
)
|
||||
stats["notes"] += _sync_notes(
|
||||
db,
|
||||
store,
|
||||
item,
|
||||
existing_id,
|
||||
)
|
||||
# Backfill collection membership for items that were
|
||||
# created before sync learned about the hierarchy.
|
||||
path = _zotero_collection_path(item)
|
||||
@@ -371,6 +378,12 @@ def push_to_zotero(
|
||||
item_id,
|
||||
storage_dir,
|
||||
)
|
||||
stats["notes"] += _sync_notes(
|
||||
db,
|
||||
store,
|
||||
item,
|
||||
item_id,
|
||||
)
|
||||
|
||||
stats["created"] += 1
|
||||
|
||||
@@ -489,3 +502,44 @@ def _sync_attachments(
|
||||
)
|
||||
count += 1
|
||||
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
|
||||
|
||||
@@ -100,6 +100,25 @@ CREATE TABLE IF NOT EXISTS creatorTypes (
|
||||
creatorTypeID INTEGER PRIMARY KEY,
|
||||
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")
|
||||
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
|
||||
|
||||
@@ -147,8 +147,7 @@ class TestPushToZotero:
|
||||
src.parent.mkdir()
|
||||
src.write_bytes(b"%PDF content")
|
||||
|
||||
store = MagicMock()
|
||||
store._con.return_value.execute.return_value.fetchall.return_value = [
|
||||
att_rows = [
|
||||
{
|
||||
"filename": "paper.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.doc_type = "Public Comment"
|
||||
|
||||
@@ -223,8 +231,7 @@ class TestPushToZoteroExistingWithAttachments:
|
||||
src.parent.mkdir()
|
||||
src.write_bytes(b"%PDF content")
|
||||
|
||||
store = MagicMock()
|
||||
store._con.return_value.execute.return_value.fetchall.return_value = [
|
||||
att_rows = [
|
||||
{
|
||||
"filename": "paper.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(
|
||||
title="IOM Ch1",
|
||||
url="https://example.com/iom-ch1",
|
||||
|
||||
Reference in New Issue
Block a user