feat(bib): place FR jump links as Zotero child link attachments + fr-jump CLI (closes #637)
This commit is contained in:
@@ -401,3 +401,21 @@ def md_link(ref: str, *, store: Store, item_key: str = "", text: str = "") -> st
|
|||||||
"""Markdown ``[text](url)`` for a resolved FR reference."""
|
"""Markdown ``[text](url)`` for a resolved FR reference."""
|
||||||
link = resolve(ref, store=store, item_key=item_key)
|
link = resolve(ref, store=store, item_key=item_key)
|
||||||
return f"[{text or ref}]({link.url})"
|
return f"[{text or ref}]({link.url})"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Place ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def place(store: Store, ref: str, *, item_key: str = "", label: str = "") -> JumpLink:
|
||||||
|
"""Resolve ``ref`` and record it in ``fr_links`` — the curated set
|
||||||
|
of cited jump links that ``bib.sync`` carries into Zotero as child
|
||||||
|
link attachments. Idempotent per (item_key, url)."""
|
||||||
|
link = resolve(ref, store=store, item_key=item_key)
|
||||||
|
con = store._con() # noqa: SLF001
|
||||||
|
con.execute(
|
||||||
|
"INSERT OR IGNORE INTO fr_links (item_key, p_id, page, label, url) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(link.item_key, link.p_id, link.page, label or ref, link.url),
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
return link
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ def push_to_zotero(
|
|||||||
"collections": 0,
|
"collections": 0,
|
||||||
"attachments": 0,
|
"attachments": 0,
|
||||||
"notes": 0,
|
"notes": 0,
|
||||||
|
"fr_links": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Resolve a tuple-path to a Zotero collection key, ensuring each
|
# Resolve a tuple-path to a Zotero collection key, ensuring each
|
||||||
@@ -312,6 +313,12 @@ def push_to_zotero(
|
|||||||
item,
|
item,
|
||||||
existing_id,
|
existing_id,
|
||||||
)
|
)
|
||||||
|
stats["fr_links"] += _sync_fr_links(
|
||||||
|
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)
|
||||||
@@ -384,6 +391,12 @@ def push_to_zotero(
|
|||||||
item,
|
item,
|
||||||
item_id,
|
item_id,
|
||||||
)
|
)
|
||||||
|
stats["fr_links"] += _sync_fr_links(
|
||||||
|
db,
|
||||||
|
store,
|
||||||
|
item,
|
||||||
|
item_id,
|
||||||
|
)
|
||||||
|
|
||||||
stats["created"] += 1
|
stats["created"] += 1
|
||||||
|
|
||||||
@@ -504,6 +517,55 @@ def _sync_attachments(
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_fr_links(
|
||||||
|
db: Db,
|
||||||
|
store: Store,
|
||||||
|
bib_item: Item,
|
||||||
|
zot_parent_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""Mirror ``fr_links`` rows (placed FR jump links, #637) as Zotero
|
||||||
|
child **link** attachments — ``linkMode=3`` (linked_url) with the
|
||||||
|
URL and label in itemData. Idempotent by the parent's existing
|
||||||
|
child-attachment URLs. Only placed links sync; the full anchor map
|
||||||
|
(~5k paragraphs per rule) never does."""
|
||||||
|
con = store._con() # noqa: SLF001
|
||||||
|
try:
|
||||||
|
rows = con.execute(
|
||||||
|
"SELECT label, url FROM fr_links WHERE item_key = ?",
|
||||||
|
(bib_item.key,),
|
||||||
|
).fetchall()
|
||||||
|
except Exception: # noqa: BLE001 — store predates the fr_links table
|
||||||
|
return 0
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
existing = {
|
||||||
|
r[0]
|
||||||
|
for r in db.con.execute(
|
||||||
|
"""SELECT idv.value FROM itemAttachments ia
|
||||||
|
JOIN itemData d ON d.itemID = ia.itemID
|
||||||
|
JOIN itemDataValues idv ON idv.valueID = d.valueID
|
||||||
|
JOIN fields f ON f.fieldID = d.fieldID
|
||||||
|
WHERE ia.parentItemID = ? AND f.fieldName = 'url'""",
|
||||||
|
(zot_parent_id,),
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for row in rows:
|
||||||
|
if row["url"] in existing:
|
||||||
|
continue
|
||||||
|
att_id = db.add_attachment(
|
||||||
|
zot_parent_id,
|
||||||
|
link_mode=3, # Zotero.Attachments.LINK_MODE_LINKED_URL
|
||||||
|
content_type="text/html",
|
||||||
|
key=generate_key(),
|
||||||
|
)
|
||||||
|
db.set_fields(att_id, {"url": row["url"], "title": row["label"]})
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
def _sync_notes(
|
def _sync_notes(
|
||||||
db: Db,
|
db: Db,
|
||||||
store: Store,
|
store: Store,
|
||||||
|
|||||||
@@ -697,3 +697,33 @@ def fr_grab(
|
|||||||
f" {r['item_key']}: {r['document_number']} — "
|
f" {r['item_key']}: {r['document_number']} — "
|
||||||
f"{r['n_paragraphs']} paragraphs / {r['n_pages']} pages"
|
f"{r['n_paragraphs']} paragraphs / {r['n_pages']} pages"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="fr-jump")
|
||||||
|
def fr_jump(
|
||||||
|
ref: str = typer.Argument(
|
||||||
|
...,
|
||||||
|
help='FR reference: "91 FR 44218", "91 FR 44218 ¶3", "p-3600", or a quote.',
|
||||||
|
),
|
||||||
|
key: str = typer.Option("", "--key", help="Bib item key to scope resolution."),
|
||||||
|
md: bool = typer.Option(False, "--md", help="Print a markdown link."),
|
||||||
|
do_place: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--place",
|
||||||
|
help="Record the link in fr_links so the next zotero sync carries it "
|
||||||
|
"as a child link attachment.",
|
||||||
|
),
|
||||||
|
label: str = typer.Option("", "--label", help="Label for the placed link."),
|
||||||
|
) -> None:
|
||||||
|
"""Resolve an FR citation to its web jump link (#637); optionally place it."""
|
||||||
|
from bib import connect, frlink
|
||||||
|
|
||||||
|
store = connect()
|
||||||
|
if do_place:
|
||||||
|
link = frlink.place(store, ref, item_key=key, label=label)
|
||||||
|
typer.echo(f"placed on {link.item_key}: {link.url}")
|
||||||
|
else:
|
||||||
|
link = frlink.resolve(ref, store=store, item_key=key)
|
||||||
|
typer.echo(frlink.md_link(ref, store=store, item_key=key) if md else link.url)
|
||||||
|
if link.snippet:
|
||||||
|
typer.echo(f' ¶ "{link.snippet[:100]}..."')
|
||||||
|
|||||||
@@ -239,3 +239,32 @@ class TestMdLink:
|
|||||||
md = frlink.md_link("91 FR 100", store=s, text="the CF discussion")
|
md = frlink.md_link("91 FR 100", store=s, text="the CF discussion")
|
||||||
assert md == "[the CF discussion](https://example.test/doc#page-100)"
|
assert md == "[the CF discussion](https://example.test/doc#page-100)"
|
||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── place() (#637) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlace:
|
||||||
|
def test_place_writes_fr_links_row(self) -> None:
|
||||||
|
s, key = _grabbed_store()
|
||||||
|
link = frlink.place(s, "91 FR 100 ¶2", label="the markup para")
|
||||||
|
assert link.p_id == 2
|
||||||
|
con = s._con() # noqa: SLF001
|
||||||
|
rows = con.execute(
|
||||||
|
"SELECT item_key, p_id, page, label, url FROM fr_links"
|
||||||
|
).fetchall()
|
||||||
|
assert [tuple(r) for r in rows] == [
|
||||||
|
(key, 2, 100, "the markup para", "https://example.test/doc#p-2")
|
||||||
|
]
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
def test_place_default_label_and_idempotency(self) -> None:
|
||||||
|
s, _key = _grabbed_store()
|
||||||
|
frlink.place(s, "91 FR 100")
|
||||||
|
frlink.place(s, "91 FR 100") # same URL → no duplicate
|
||||||
|
con = s._con() # noqa: SLF001
|
||||||
|
rows = con.execute("SELECT label, url, p_id FROM fr_links").fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["label"] == "91 FR 100"
|
||||||
|
assert rows[0]["p_id"] is None
|
||||||
|
s.close()
|
||||||
|
|||||||
@@ -555,3 +555,69 @@ class TestDedupeCollection:
|
|||||||
"removed": 0,
|
"removed": 0,
|
||||||
}
|
}
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── FR jump links → child link attachments (#637) ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncFrLinks:
|
||||||
|
def _bib_with_placed_link(self, tmp_path):
|
||||||
|
from bib import frlink
|
||||||
|
from bib.store import Store
|
||||||
|
from tests.bib.test_frlink import DOC_META, FIXTURE_HTML
|
||||||
|
|
||||||
|
store = Store(str(tmp_path / "bib.sqlite"))
|
||||||
|
key = store.create(
|
||||||
|
Rule(
|
||||||
|
key="RUEFR423",
|
||||||
|
title="FR Rule",
|
||||||
|
url="https://www.federalregister.gov/documents/2026/07/16/2026-14327/x",
|
||||||
|
document_number="2026-14327",
|
||||||
|
fr_volume="91",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
frlink.grab(store, key, fetch=lambda _doc: (DOC_META, FIXTURE_HTML))
|
||||||
|
frlink.place(store, "91 FR 100 ¶2", label="¶ p-2 — 91 FR 100")
|
||||||
|
return store, key
|
||||||
|
|
||||||
|
def test_push_creates_linkmode3_child(self, tmp_path) -> None:
|
||||||
|
db_path = str(tmp_path / "zotero.sqlite")
|
||||||
|
_make_zotero_db(db_path).close()
|
||||||
|
store, key = self._bib_with_placed_link(tmp_path)
|
||||||
|
|
||||||
|
stats = push_to_zotero([store.get(key)], store=store, zotero_db=db_path)
|
||||||
|
assert stats["fr_links"] == 1
|
||||||
|
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
child = con.execute(
|
||||||
|
"""SELECT ia.linkMode, ia.contentType FROM itemAttachments ia
|
||||||
|
JOIN items p ON p.itemID = ia.parentItemID
|
||||||
|
WHERE p.key = 'RUEFR423'"""
|
||||||
|
).fetchone()
|
||||||
|
assert (child["linkMode"], child["contentType"]) == (3, "text/html")
|
||||||
|
url = con.execute(
|
||||||
|
"""SELECT idv.value FROM itemAttachments ia
|
||||||
|
JOIN itemData d ON d.itemID = ia.itemID
|
||||||
|
JOIN itemDataValues idv ON idv.valueID = d.valueID
|
||||||
|
JOIN fields f ON f.fieldID = d.fieldID
|
||||||
|
WHERE f.fieldName = 'url'"""
|
||||||
|
).fetchone()[0]
|
||||||
|
assert url == "https://example.test/doc#p-2"
|
||||||
|
con.close()
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_second_push_is_idempotent(self, tmp_path) -> None:
|
||||||
|
db_path = str(tmp_path / "zotero.sqlite")
|
||||||
|
_make_zotero_db(db_path).close()
|
||||||
|
store, key = self._bib_with_placed_link(tmp_path)
|
||||||
|
|
||||||
|
push_to_zotero([store.get(key)], store=store, zotero_db=db_path)
|
||||||
|
stats = push_to_zotero([store.get(key)], store=store, zotero_db=db_path)
|
||||||
|
assert stats["fr_links"] == 0
|
||||||
|
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
n = con.execute("SELECT count(*) FROM itemAttachments").fetchone()[0]
|
||||||
|
assert n == 1
|
||||||
|
con.close()
|
||||||
|
store.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user