All checks were successful
CI / lint (push) Successful in 35s
CI / notebooks-smoke (push) Successful in 1m25s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 58s
Infra CI / zotero (push) Successful in 18s
Infra CI / docs (push) Successful in 17s
Infra CI / api (push) Successful in 1m9s
Infra CI / llm (push) Successful in 45s
Infra CI / mc (push) Successful in 13s
Deploy / report (push) Successful in 13s
CI / test (push) Successful in 14m14s
The exporter read tags in a second query after the minutes-long item hydration; a tag edit landing in between (P37 controller tag ops during #618's suite run) produced a library.json whose item tags were missing from the tag list, failing test_tags_in_items_match_tag_list on every run until regeneration. The tag list is now derived from the serialized items themselves (self-consistent by construction) and the output file is swapped in atomically. Tests no longer assert on live data/bib.sqlite: TestExportLibrary exports a fixture store via new STACK_BIB_DB/STACK_LIBRARY_JSON overrides — the live-store contract is the docs build's own concern. Co-designed with a peer session that landed the derived-tags exporter.
106 lines
3.5 KiB
Python
106 lines
3.5 KiB
Python
"""Export bib.Store contents to static JSON for the library browser.
|
|
|
|
Reads ``data/bib.sqlite`` and writes ``docs/static/library.json``
|
|
with items, collections, and tags. Gracefully writes empty JSON if
|
|
the database is missing.
|
|
|
|
The tag list is derived from the serialized items rather than queried
|
|
separately, so the export is self-consistent even if a writer mutates
|
|
tags during the minutes-long item scan (#623), and the output file is
|
|
swapped in atomically so concurrent readers never see a partial write.
|
|
|
|
``STACK_BIB_DB`` / ``STACK_LIBRARY_JSON`` override the default paths
|
|
(used by the hermetic tests in tests/docs/test_docs_build.py).
|
|
|
|
Usage::
|
|
|
|
uv run python docs/scripts/export_library.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
BIB_DB = Path(os.environ.get("STACK_BIB_DB", "data/bib.sqlite"))
|
|
OUT_PATH = Path(os.environ.get("STACK_LIBRARY_JSON", "docs/static/library.json"))
|
|
|
|
EMPTY = {"items": [], "collections": [], "tags": []}
|
|
|
|
|
|
def _write(data: dict) -> None:
|
|
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = OUT_PATH.with_name(OUT_PATH.name + ".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2, default=str))
|
|
os.replace(tmp, OUT_PATH)
|
|
|
|
|
|
def main() -> None:
|
|
if not BIB_DB.exists():
|
|
print(f"bib.sqlite not found at {BIB_DB}, writing empty library.json")
|
|
_write(EMPTY)
|
|
return
|
|
|
|
# Import here so griffe stage doesn't need bib deps
|
|
sys.path.insert(0, str(Path("src").resolve()))
|
|
try:
|
|
from bib.store import Store
|
|
except ImportError as exc:
|
|
print(f"bib deps not available ({exc}), writing empty library.json")
|
|
_write(EMPTY)
|
|
return
|
|
|
|
store = Store(str(BIB_DB))
|
|
con = store._con()
|
|
items = store.list_items()
|
|
raw_collections = store.list_collections()
|
|
|
|
# item_count from list_collections() is direct-only, which is what
|
|
# the library page filter uses (i.collections.includes(key)).
|
|
# Drop collections with 0 direct items — they produce empty results.
|
|
collections = [c for c in raw_collections if c["item_count"] > 0]
|
|
|
|
serialized_items = []
|
|
tag_counts: Counter[str] = Counter()
|
|
for item in items:
|
|
creator_rows = con.execute(
|
|
"SELECT c.last_name, c.first_name FROM item_creators ic "
|
|
"JOIN creators c ON ic.creator_id = c.id "
|
|
"WHERE ic.item_id = (SELECT id FROM items WHERE key = ?) "
|
|
"ORDER BY ic.sort_order",
|
|
(item.key,),
|
|
).fetchall()
|
|
creators = [
|
|
f"{r['last_name']}, {r['first_name']}"
|
|
if r["first_name"]
|
|
else r["last_name"]
|
|
for r in creator_rows
|
|
]
|
|
tag_counts.update(item.tags)
|
|
serialized_items.append(
|
|
{
|
|
"key": item.key,
|
|
"item_type": item.item_type,
|
|
"title": item.title,
|
|
"url": item.url,
|
|
"date_published": item.date_published,
|
|
"abstract": item.abstract or "",
|
|
"tags": item.tags,
|
|
"collections": item.collections,
|
|
"institution": item.institution,
|
|
"creators": creators,
|
|
}
|
|
)
|
|
store.close()
|
|
|
|
tags = [{"name": n, "count": c} for n, c in sorted(tag_counts.items())]
|
|
_write({"items": serialized_items, "collections": collections, "tags": tags})
|
|
print(f"Wrote {len(serialized_items)} items to {OUT_PATH}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|