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,
|
"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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user