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."""
|
||||
link = resolve(ref, store=store, item_key=item_key)
|
||||
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,
|
||||
"attachments": 0,
|
||||
"notes": 0,
|
||||
"fr_links": 0,
|
||||
}
|
||||
|
||||
# Resolve a tuple-path to a Zotero collection key, ensuring each
|
||||
@@ -312,6 +313,12 @@ def push_to_zotero(
|
||||
item,
|
||||
existing_id,
|
||||
)
|
||||
stats["fr_links"] += _sync_fr_links(
|
||||
db,
|
||||
store,
|
||||
item,
|
||||
existing_id,
|
||||
)
|
||||
# Backfill collection membership for items that were
|
||||
# created before sync learned about the hierarchy.
|
||||
path = _zotero_collection_path(item)
|
||||
@@ -384,6 +391,12 @@ def push_to_zotero(
|
||||
item,
|
||||
item_id,
|
||||
)
|
||||
stats["fr_links"] += _sync_fr_links(
|
||||
db,
|
||||
store,
|
||||
item,
|
||||
item_id,
|
||||
)
|
||||
|
||||
stats["created"] += 1
|
||||
|
||||
@@ -504,6 +517,55 @@ def _sync_attachments(
|
||||
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(
|
||||
db: Db,
|
||||
store: Store,
|
||||
|
||||
@@ -697,3 +697,33 @@ def fr_grab(
|
||||
f" {r['item_key']}: {r['document_number']} — "
|
||||
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")
|
||||
assert md == "[the CF discussion](https://example.test/doc#page-100)"
|
||||
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,
|
||||
}
|
||||
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