feat(bib): place FR jump links as Zotero child link attachments + fr-jump CLI (closes #637)

This commit is contained in:
kert
2026-08-17 14:39:28 -04:00
parent eda1887628
commit e5e5eb8659
5 changed files with 205 additions and 0 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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]}..."')

View File

@@ -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()

View File

@@ -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()