Files
stack/src/bib/store.py
kert b5e07e67b2 feat(bib): discover IPPS, OPPS and DMEPOS rules and pull their comments
The PFS register-and-walk path now serves the annual IPPS/LTCH and
OPPS/ASC rules and DMEPOS payment rules: title-filtered FR discovery
(discover-*-rules) and docket registration + comment walks
(fetch-*-comments). Correction documents (C1-...) parse, a null FR
abstract no longer becomes 'None', and the store waits out the mail
poller's write bursts (busy_timeout) instead of failing SQLITE_BUSY.
2026-11-11 12:53:22 -05:00

888 lines
31 KiB
Python

"""Own-storage bibliography backend — replaces Zotero contexts.
``Store`` owns a SQLite database with the denormalized 8-table schema
(``schema.sql``) and provides all CRUD, querying, tagging, attachment,
and citation-formatting operations.
Usage::
from bib.store import Store
from bib.item import Rule
s = Store()
key = s.create(Rule(title="CY 2026 PFS Final Rule", fr_volume="90"))
item = s.get(key)
print(s.format_citation(key))
"""
from __future__ import annotations
import json
import mimetypes
import random
import shutil
import sqlite3
from importlib import resources
from pathlib import Path
from typing import Any, Iterable
from bib.dockets import Docket
from bib.item import Item
def _generate_key() -> str:
"""8-char key using Zotero-compatible charset (no 0, 1, L, O)."""
chars = "23456789ABCDEFGHIJKMNPQRSTUVWXYZ"
return "".join(random.choices(chars, k=8)) # noqa: S311
class Store:
"""SQLite-backed bibliography store.
Parameters
----------
database : str or Path
Path to the SQLite file. Use ``":memory:"`` for in-memory.
storage_dir : str or Path, optional
Directory for file attachments. Defaults to a ``storage/``
sibling of the database file.
"""
def __init__(
self,
database: str | Path | None = None,
*,
storage_dir: str | Path = "",
) -> None:
if database is None:
from conf import path
database = str(path("db.bib"))
self._db_path = str(database)
self._connection: sqlite3.Connection | None = None
if storage_dir:
self._storage = Path(storage_dir)
elif self._db_path == ":memory:":
self._storage = Path("storage")
else:
self._storage = Path(self._db_path).parent / "storage"
self._init_schema()
# ── Connection / schema ──────────────────────────────────────
def _con(self) -> sqlite3.Connection:
if self._connection is None:
self._connection = sqlite3.connect(self._db_path)
self._connection.row_factory = sqlite3.Row
self._connection.execute("PRAGMA journal_mode=WAL")
self._connection.execute("PRAGMA foreign_keys=ON")
# The mail poller writes this file on its own cadence. A comment
# walk waits through that burst instead of dying on SQLITE_BUSY.
self._connection.execute("PRAGMA busy_timeout=60000")
return self._connection
def _init_schema(self) -> None:
ddl = resources.files("bib").joinpath("schema.sql").read_text()
self._con().executescript(ddl)
# The (item_id, filename) unique index can only exist once the
# dedupe migration has run (dev/scripts/dedupe_attachments.py);
# until then we leave it off rather than fail to open the store.
# Once it exists, sqlite_master says so directly — skip the
# GROUP BY scan (which otherwise runs on every Store() open).
con = self._con()
indexed = con.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'index' "
"AND name = 'idx_attachments_item_filename'"
).fetchone()
if indexed is not None:
return
dup = con.execute(
"SELECT 1 FROM attachments GROUP BY item_id, filename HAVING count(*) > 1 LIMIT 1"
).fetchone()
if dup is None:
con.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_item_filename "
"ON attachments(item_id, filename)"
)
con.commit()
def close(self) -> None:
if self._connection is not None:
self._connection.close()
self._connection = None
def __del__(self) -> None:
try:
self.close()
except Exception:
pass
# ── Item CRUD ────────────────────────────────────────────────
def create(
self,
item: Item,
*,
tags: list[Any] | None = None,
collection: str = "",
) -> str:
"""Insert a new item. Returns the item key."""
if tags:
for tag in tags:
label = tag.label if hasattr(tag, "label") else str(tag)
item.add_tag(label)
if collection and collection not in item.collections:
item.collections.append(collection)
item.stamp_access()
row = item.to_row()
key = row["key"] or _generate_key()
row["key"] = key
con = self._con()
con.execute(
"""INSERT INTO items
(key, item_type, title, url, date_published,
access_date, abstract, institution, extra, extra_json)
VALUES (:key, :item_type, :title, :url,
:date_published, :access_date, :abstract,
:institution, :extra, :extra_json)""",
row,
)
item_id = con.execute("SELECT id FROM items WHERE key = ?", (key,)).fetchone()[
"id"
]
self._sync_tags(item_id, item.tags)
self._sync_collections(item_id, item.collections)
con.commit()
return key
def get(self, key: str) -> Item:
"""Fetch an item by key."""
con = self._con()
row = con.execute("SELECT * FROM items WHERE key = ?", (key,)).fetchone()
if row is None:
raise KeyError(f"Item not found: {key}")
d = dict(row)
item_id = d.pop("id")
d.pop("created_at", None)
d.pop("updated_at", None)
# Load tags
tag_rows = con.execute(
"""SELECT t.name FROM item_tags it
JOIN tags t ON it.tag_id = t.id
WHERE it.item_id = ?""",
(item_id,),
).fetchall()
d["tags"] = [r["name"] for r in tag_rows]
# Load collections
col_rows = con.execute(
"""SELECT c.key FROM collection_items ci
JOIN collections c ON ci.collection_id = c.id
WHERE ci.item_id = ?""",
(item_id,),
).fetchall()
d["collections"] = [r["key"] for r in col_rows]
return Item.from_row(d)
def update(self, key: str, **fields: Any) -> None:
"""Update specific fields on an item."""
if not fields:
return
con = self._con()
# Separate tag/collection updates from column updates
new_tags = fields.pop("tags", None)
new_collections = fields.pop("collections", None)
if fields:
sets = ", ".join(f"{k} = ?" for k in fields)
vals = list(fields.values()) + [key]
con.execute(
f"UPDATE items SET {sets}, " # noqa: S608
"updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') "
f"WHERE key = ?",
vals,
)
if new_tags is not None or new_collections is not None:
row = con.execute("SELECT id FROM items WHERE key = ?", (key,)).fetchone()
if row is None:
raise KeyError(f"Item not found: {key}")
item_id = row["id"]
if new_tags is not None:
self._sync_tags(item_id, new_tags)
if new_collections is not None:
self._sync_collections(item_id, new_collections)
if not fields:
# A tags/collections-only rewrite still changes the item
# as the indexer sees it (year:/project:/cms-rule: land in
# chunk metadata), and nothing else stamped the row.
self._stamp(key)
con.commit()
def delete(self, key: str) -> None:
"""Delete an item and its associations."""
con = self._con()
con.execute("DELETE FROM items WHERE key = ?", (key,))
con.commit()
_COMPARE_COLS = (
"item_type",
"title",
"url",
"date_published",
"abstract",
"institution",
"extra",
"extra_json",
)
def upsert(
self,
item: Item,
*,
tags: list[Any] | None = None,
collection: str = "",
) -> str:
"""Create or update an item, deduplicating by URL."""
return self.upsert_status(item, tags=tags, collection=collection)[0]
def upsert_status(
self,
item: Item,
*,
tags: list[Any] | None = None,
collection: str = "",
) -> tuple[str, str]:
"""Like :meth:`upsert` but also report what happened:
``"created"``, ``"updated"`` or ``"unchanged"``.
``unchanged`` means every compared column, the merged tag set and
the merged collection set already match the stored row — nothing
is written, no ``access_date``/``updated_at`` stamp, no tag row
churn. A re-farm over a complete docket is therefore a read-only
pass over ``items``.
Merge rules, all of which make ``unchanged`` a comparison against
the *merged* row rather than the incoming one:
* columns: an empty incoming value keeps the stored one (a
list-walk row carries no body, and must not blank an enriched
abstract). ``extra_json`` is merged per key instead of
column-wise: a stored key survives unless the incoming
payload sets that key to a non-empty value, so a list-walk
row's empty subclass fields (``{"effective_date": "", ...}``)
never blank an already-stored value.
* tags and collections: union-merged with what is stored, never
replaced. Removal goes through :meth:`remove_tag`.
"""
if tags:
for tag in tags:
label = tag.label if hasattr(tag, "label") else str(tag)
item.add_tag(label)
if collection and collection not in item.collections:
item.collections.append(collection)
if not item.url:
return self.create(item), "created"
con = self._con()
existing = con.execute(
"SELECT key FROM items WHERE url = ?", (item.url,)
).fetchone()
if not existing:
return self.create(item), "created"
ekey = existing["key"]
# Merge with what's already stored — a re-ingest must never
# clobber tags/collections curated on the row since the last
# ingest (#624). Deliberate removal goes through remove_tag.
current = self.get(ekey)
merged_tags = list(dict.fromkeys([*current.tags, *item.tags]))
merged_cols = list(dict.fromkeys([*current.collections, *item.collections]))
new_row = item.to_row()
cur_row = current.to_row()
# Merge, don't replace: an empty incoming value carries no
# information (a list-walk row has no body), so the stored value
# wins.
for c in self._COMPARE_COLS:
if c != "extra_json" and not new_row.get(c) and cur_row.get(c):
new_row[c] = cur_row[c]
setattr(item, c, cur_row[c])
# extra_json is a full serialization of subclass-specific fields
# every time, so a column-wise empty check can't apply — merge
# the decoded dict per key instead: a stored key survives unless
# the incoming payload sets it to a non-empty value.
cur_extra = json.loads(cur_row.get("extra_json") or "{}")
new_extra = json.loads(new_row.get("extra_json") or "{}")
merged_extra = {**cur_extra, **{k: v for k, v in new_extra.items() if v}}
merged_extra_json = json.dumps(merged_extra)
new_row["extra_json"] = merged_extra_json
same_cols = all(
new_row.get(c, "") == cur_row.get(c, "") for c in self._COMPARE_COLS
)
if (
same_cols
and set(merged_tags) == set(current.tags)
and set(merged_cols) == set(current.collections)
):
return ekey, "unchanged"
item.stamp_access()
row = item.to_row()
row.pop("key", None)
row["extra_json"] = merged_extra_json
row["tags"] = merged_tags
row["collections"] = merged_cols
self.update(ekey, **row)
return ekey, "updated"
# ── Query ────────────────────────────────────────────────────
def _filter_clause(
self, tag: str, item_type: str, collection: str, query: str
) -> tuple[str, list[Any]]:
"""Build the shared JOIN/WHERE suffix for item filters."""
joins: list[str] = []
wheres: list[str] = []
params: list[Any] = []
if tag:
joins.append(
" JOIN item_tags it ON i.id = it.item_id"
" JOIN tags t ON it.tag_id = t.id"
)
wheres.append("t.name = ?")
params.append(tag)
if collection:
joins.append(
" JOIN collection_items ci ON i.id = ci.item_id"
" JOIN collections c ON ci.collection_id = c.id"
)
wheres.append("c.key = ?")
params.append(collection)
if item_type:
wheres.append("i.item_type = ?")
params.append(item_type)
if query:
wheres.append("(i.title LIKE ? OR i.abstract LIKE ?)")
params.extend([f"%{query}%", f"%{query}%"])
clause = "".join(joins)
if wheres:
clause += " WHERE " + " AND ".join(wheres)
return clause, params
def list_items(
self,
*,
tag: str = "",
item_type: str = "",
collection: str = "",
query: str = "",
limit: int | None = None,
) -> list[Item]:
"""List items with optional filters.
Pass ``limit`` to bound the scan — each returned item costs a
``get()`` round-trip, so an unbounded call on a large store
(Zotero sync is ~180k items) takes minutes.
"""
con = self._con()
clause, params = self._filter_clause(tag, item_type, collection, query)
full_sql = f"SELECT DISTINCT i.key FROM items i{clause} ORDER BY i.id"
if limit is not None:
full_sql += " LIMIT ?"
params.append(limit)
rows = con.execute(full_sql, params).fetchall()
return [self.get(r["key"]) for r in rows]
def count(
self,
*,
tag: str = "",
item_type: str = "",
collection: str = "",
query: str = "",
) -> int:
"""Count items matching filters (single COUNT query — no item
hydration; ``len(list_items())`` is O(n) get() calls)."""
con = self._con()
clause, params = self._filter_clause(tag, item_type, collection, query)
full_sql = f"SELECT COUNT(DISTINCT i.key) AS n FROM items i{clause}"
return int(con.execute(full_sql, params).fetchone()["n"])
def to_dataframe(self, **filters: Any) -> Any:
"""Export items as a polars DataFrame (lazy import)."""
import polars as pl
items = self.list_items(**filters)
rows = []
for item in items:
rows.append(
{
"key": item.key,
"item_type": item.item_type,
"title": item.title,
"url": item.url,
"date_published": item.date_published,
"tags": "; ".join(item.tags),
"institution": item.institution,
}
)
return pl.DataFrame(rows)
# ── Collections ──────────────────────────────────────────────
def list_collections(self) -> list[dict[str, Any]]:
"""List all collections."""
con = self._con()
rows = con.execute(
"""SELECT c.key, c.name, pc.key AS parent_key,
(SELECT count(*) FROM collection_items ci
WHERE ci.collection_id = c.id) AS item_count
FROM collections c
LEFT JOIN collections pc ON c.parent_id = pc.id
ORDER BY c.name"""
).fetchall()
return [
{
"key": r["key"],
"name": r["name"],
"parent_key": r["parent_key"] or "",
"item_count": r["item_count"],
}
for r in rows
]
def ensure_collections(
self,
hierarchy: dict[str, dict],
parent_key: str | None = None,
) -> dict[str, str]:
"""Create collections from a nested dict. Returns {name: key}."""
con = self._con()
result: dict[str, str] = {}
parent_id: int | None = None
if parent_key:
row = con.execute(
"SELECT id FROM collections WHERE key = ?",
(parent_key,),
).fetchone()
if row:
parent_id = row["id"]
for name, children in hierarchy.items():
if parent_id is not None:
row = con.execute(
"SELECT id, key FROM collections WHERE name = ? AND parent_id = ?",
(name, parent_id),
).fetchone()
else:
row = con.execute(
"SELECT id, key FROM collections "
"WHERE name = ? AND parent_id IS NULL",
(name,),
).fetchone()
if row:
key = row["key"]
else:
key = _generate_key()
con.execute(
"INSERT INTO collections (key, name, parent_id) VALUES (?, ?, ?)",
(key, name, parent_id),
)
result[name] = key
if children:
child_map = self.ensure_collections(children, parent_key=key)
result.update(child_map)
con.commit()
return result
# ── Tags ─────────────────────────────────────────────────────
def _ensure_tag(self, name: str) -> int:
con = self._con()
row = con.execute("SELECT id FROM tags WHERE name = ?", (name,)).fetchone()
if row:
return row["id"]
cur = con.execute("INSERT INTO tags (name) VALUES (?)", (name,))
return cur.lastrowid # type: ignore[return-value]
def _sync_tags(self, item_id: int, tag_names: list[str]) -> None:
con = self._con()
con.execute("DELETE FROM item_tags WHERE item_id = ?", (item_id,))
for name in tag_names:
if not name:
continue
tag_id = self._ensure_tag(name)
con.execute(
"INSERT OR IGNORE INTO item_tags (item_id, tag_id) VALUES (?, ?)",
(item_id, tag_id),
)
def _sync_collections(self, item_id: int, collection_keys: list[str]) -> None:
con = self._con()
con.execute("DELETE FROM collection_items WHERE item_id = ?", (item_id,))
for ckey in collection_keys:
row = con.execute(
"SELECT id FROM collections WHERE key = ?", (ckey,)
).fetchone()
if row:
con.execute(
"INSERT OR IGNORE INTO collection_items "
"(collection_id, item_id) VALUES (?, ?)",
(row["id"], item_id),
)
def _stamp(self, item_key: str) -> None:
"""Mark the item row as changed *now*.
Tags carry indexed metadata (``year:``, ``project:``,
``cms-rule:``), so a tag-only edit has to move ``updated_at`` or
the llm indexer's fingerprint can never see it (#615).
"""
self._con().execute(
"UPDATE items SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') "
"WHERE key = ?",
(item_key,),
)
def add_tag(self, item_key: str, tag: str) -> None:
"""Add a single tag to an item."""
con = self._con()
row = con.execute("SELECT id FROM items WHERE key = ?", (item_key,)).fetchone()
if row is None:
raise KeyError(f"Item not found: {item_key}")
tag_id = self._ensure_tag(tag)
cur = con.execute(
"INSERT OR IGNORE INTO item_tags (item_id, tag_id) VALUES (?, ?)",
(row["id"], tag_id),
)
if cur.rowcount: # re-tagging an already-tagged item changes nothing
self._stamp(item_key)
con.commit()
def add_tags(self, item_key: str, tags: Iterable[str]) -> int:
"""Add multiple tags to an item in one transaction.
Same ``INSERT OR IGNORE`` semantics as :meth:`add_tag` — an
already-present tag is a no-op — but batched into a single
commit instead of one per tag. A backfill stamping ~486k
``code:``/``family:`` labels one-at-a-time via ``add_tag`` took
34 minutes, almost entirely commit/fsync overhead
(``bib.codetags.apply_code_tags``).
Returns the number of tags newly added (0 if every tag in
*tags* was already on the item).
"""
con = self._con()
row = con.execute("SELECT id FROM items WHERE key = ?", (item_key,)).fetchone()
if row is None:
raise KeyError(f"Item not found: {item_key}")
item_id = row["id"]
added = 0
for tag in tags:
tag_id = self._ensure_tag(tag)
cur = con.execute(
"INSERT OR IGNORE INTO item_tags (item_id, tag_id) VALUES (?, ?)",
(item_id, tag_id),
)
added += cur.rowcount
if added: # re-tagging with already-present tags only changes nothing
self._stamp(item_key)
con.commit()
return added
def merge_extra(self, item_key: str, updates: dict[str, Any]) -> None:
"""Merge *updates* into the item's ``extra_json`` (top-level keys
replace; everything else is kept). A missing item is a no-op."""
con = self._con()
row = con.execute(
"SELECT extra_json FROM items WHERE key = ?", (item_key,)
).fetchone()
if row is None:
return
current = json.loads(row[0] or "{}") if row[0] else {}
current.update(updates)
con.execute(
"UPDATE items SET extra_json = ?, "
"updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE key = ?",
(json.dumps(current), item_key),
)
con.commit()
def remove_tag(self, item_key: str, tag: str) -> None:
"""Remove a tag from an item."""
con = self._con()
cur = con.execute(
"""DELETE FROM item_tags
WHERE item_id = (SELECT id FROM items WHERE key = ?)
AND tag_id = (SELECT id FROM tags WHERE name = ?)""",
(item_key, tag),
)
if cur.rowcount: # the tag wasn't there — nothing changed
self._stamp(item_key)
con.commit()
def list_tags(self, *, namespace: str = "") -> list[dict[str, int]]:
"""List tags with item counts. Filter by namespace prefix."""
con = self._con()
if namespace:
rows = con.execute(
"""SELECT t.name, count(*) AS cnt
FROM tags t
JOIN item_tags it ON t.id = it.tag_id
WHERE t.name LIKE ?
GROUP BY t.name ORDER BY t.name""",
(f"{namespace}:%",),
).fetchall()
else:
rows = con.execute(
"""SELECT t.name, count(*) AS cnt
FROM tags t
JOIN item_tags it ON t.id = it.tag_id
GROUP BY t.name ORDER BY t.name"""
).fetchall()
return [{"name": r["name"], "count": r["cnt"]} for r in rows]
# ── Dockets ──────────────────────────────────────────────────
_DOCKET_COLS = (
"id",
"rule_cms_id",
"fr_document_id",
"fr_object_id",
"comment_end_date",
"pull_watermark",
"last_pull_at",
"last_pull_new",
"sealed_at",
"seal_reason",
"counts_json",
)
def _docket_from_row(self, row: sqlite3.Row | None) -> Docket | None:
if row is None:
return None
d = {c: row[c] for c in self._DOCKET_COLS}
return Docket(**d)
def docket_get(self, docket_id: str) -> Docket | None:
row = (
self._con()
.execute("SELECT * FROM dockets WHERE id = ?", (docket_id,))
.fetchone()
)
return self._docket_from_row(row)
def docket_for_rule(self, cms_rule_id: str) -> Docket | None:
row = (
self._con()
.execute(
"SELECT * FROM dockets WHERE rule_cms_id = ? ORDER BY id LIMIT 1",
(cms_rule_id,),
)
.fetchone()
)
return self._docket_from_row(row)
def docket_upsert(self, docket: Docket) -> None:
cols = ", ".join(self._DOCKET_COLS)
marks = ", ".join(f":{c}" for c in self._DOCKET_COLS)
sets = ", ".join(f"{c} = excluded.{c}" for c in self._DOCKET_COLS if c != "id")
con = self._con()
con.execute(
f"INSERT INTO dockets ({cols}) VALUES ({marks}) " # noqa: S608
f"ON CONFLICT(id) DO UPDATE SET {sets}",
{c: getattr(docket, c) for c in self._DOCKET_COLS},
)
con.commit()
def dockets(self) -> list[Docket]:
rows = self._con().execute("SELECT * FROM dockets ORDER BY id").fetchall()
return [self._docket_from_row(r) for r in rows]
def sealed_dockets(self) -> dict[str, str]:
"""docket id → sealed_at for every sealed docket."""
rows = (
self._con()
.execute("SELECT id, sealed_at FROM dockets WHERE sealed_at <> ''")
.fetchall()
)
return {r["id"]: r["sealed_at"] for r in rows}
def docket_seal(
self, docket_id: str, *, reason: str, counts: dict[str, int]
) -> None:
from datetime import datetime, timezone
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
con = self._con()
con.execute(
"UPDATE dockets SET sealed_at = ?, seal_reason = ?, counts_json = ? WHERE id = ?",
(now, reason, json.dumps(counts, sort_keys=True), docket_id),
)
con.commit()
def docket_unseal(self, docket_id: str) -> None:
con = self._con()
con.execute(
"UPDATE dockets SET sealed_at = '', seal_reason = '' WHERE id = ?",
(docket_id,),
)
con.commit()
# ── Attachments & Notes ──────────────────────────────────────
def attach_file(self, item_key: str, path: Path, *, title: str = "") -> str:
"""Copy a file into storage and link it to an item."""
con = self._con()
row = con.execute("SELECT id FROM items WHERE key = ?", (item_key,)).fetchone()
if row is None:
raise KeyError(f"Item not found: {item_key}")
filename = title or path.name
dup = con.execute(
"SELECT key FROM attachments WHERE item_id = ? AND filename = ?",
(row["id"], filename),
).fetchone()
if dup:
return dup["key"]
att_key = _generate_key()
dest_dir = self._storage / att_key
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / path.name
shutil.copy2(path, dest)
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
con.execute(
"""INSERT INTO attachments
(item_id, key, filename, content_type, storage_path)
VALUES (?, ?, ?, ?, ?)""",
(
row["id"],
att_key,
filename,
content_type,
# resolve(): through a symlinked storage dir (a git
# worktree's data/ pointing at the real data dir) the
# unresolved path dies with the worktree (#628).
str(dest.resolve()),
),
)
con.commit()
return att_key
def attach_note(self, item_key: str, content: str, *, title: str = "") -> int:
"""Add a note to an item. Returns the note ID."""
con = self._con()
row = con.execute("SELECT id FROM items WHERE key = ?", (item_key,)).fetchone()
if row is None:
raise KeyError(f"Item not found: {item_key}")
cur = con.execute(
"INSERT INTO notes (item_id, title, content) VALUES (?, ?, ?)",
(row["id"], title, content),
)
con.commit()
return cur.lastrowid # type: ignore[return-value]
# ── Citation formatting ──────────────────────────────────────
def format_citation(self, key: str, style: str = "apa") -> str:
"""Format a single item as a citation string."""
from bib.format import format_citation
item = self.get(key)
return format_citation(item, style=style)
def format_bibliography(self, keys: list[str], style: str = "apa") -> str:
"""Format multiple items as a bibliography."""
from bib.format import format_bibliography
items = [self.get(k) for k in keys]
return format_bibliography(items, style=style)
# ── Pincites ─────────────────────────────────────────────────
def upsert_pincite(
self,
fn_path: str,
item_key: str,
locator: str = "",
locator_type: str = "",
note: str = "",
) -> int:
"""Insert or update a pincite record. Returns the row id."""
from bib.pincite import Pincite, upsert_pincites
return upsert_pincites(
self,
[
Pincite(
fn_path=fn_path,
item_key=item_key,
locator=locator,
locator_type=locator_type,
note=note,
)
],
)
def list_pincites(
self,
*,
fn_path: str = "",
item_key: str = "",
) -> list[dict[str, str]]:
"""Query pincites with optional filters."""
from bib.pincite import list_pincites as _list
return [p.model_dump() for p in _list(self, fn_path=fn_path, item_key=item_key)]
def delete_pincites(self, *, fn_path: str = "", item_key: str = "") -> int:
"""Delete pincites matching filters. Returns count deleted."""
con = self._con()
try:
con.execute("SELECT 1 FROM pincites LIMIT 0")
except Exception:
return 0
sql = "DELETE FROM pincites WHERE 1=1"
params: list[str] = []
if fn_path:
sql += " AND fn_path = ?"
params.append(fn_path)
if item_key:
sql += " AND item_key = ?"
params.append(item_key)
cur = con.execute(sql, params)
con.commit()
return cur.rowcount