177c7e9ce8
Files changed: - .gitea/workflows/ci.yml - AGENTS.md - CHANGES.md - VERSION - instructions/CONTRACT.md - instructions/link-taxonomy.md - instructions/migrations/4.0.0-link-taxonomy.md - instructions/setup-instance.md - kb/CONTRACT.md - kb/CONVENTIONS.md - kb/CONVENTIONS.md.template - kb/comparisons/COLLECTION.md - kb/concepts/COLLECTION.md - kb/entities/COLLECTION.md - kb/sources/COLLECTION.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/blocks.py - tools/chemenu/cli.py - tools/chemenu/commands/cite_cmd.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/links_cmd.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/commands/new_page.py - tools/chemenu/commands/page_ops.py - tools/chemenu/commands/run_budget.py - tools/chemenu/commands/xref.py - tools/chemenu/conventions.py - tools/chemenu/corpus_diff.py - tools/chemenu/frontmatter_io.py - tools/chemenu/kb_collections.py - tools/chemenu/kb_state.py - tools/chemenu/links.py - tools/chemenu/lint_core.py - tools/chemenu/provenance.py - tools/chemenu/sections.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_blocks.py - tools/chemenu/tests/test_cite_cmd.py - tools/chemenu/tests/test_conventions.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_migrate_cmd.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_pipeline_l0.py - tools/chemenu/tests/test_types_cmd.py - tools/chemenu/tests/test_xref.py - types/concept.schema.yaml - types/entity.md - types/entity.schema.yaml - types/instruction.schema.yaml - types/type-spec.md - work/link-taxonomy-migration/README.md - work/link-taxonomy-migration/plan.md
220 lines
8.8 KiB
Python
220 lines
8.8 KiB
Python
"""`wikitool cite ...` - real GFM footnote citations.
|
|
|
|
A citation marker is `[^cite-id]` in a page's prose, resolved by a
|
|
`[^cite-id]: [[Source - X]]` (or `[[Source - X|file.md]]`) definition line in
|
|
the page's trailing Footnotes block (see chemenu.provenance for the
|
|
regexes and cite_id() derivation). AGENTS.md invariant 1 forbids hand-writing
|
|
generated structure, and a cite-id is exactly that - an author must never
|
|
compute or paste one by hand. `cite add` is the only way to get one onto a
|
|
page; `cite sync` is the only way to reconcile a page's block after prose
|
|
edits changed which ids are actually referenced.
|
|
|
|
None of this writes the inline `[^cite-id]` reference into prose: where a
|
|
citation belongs in a sentence is an editorial call, same as the prose itself
|
|
(see tools/CONTRACT.md's design notes). `cite add` prints the marker to paste
|
|
in; the LLM places it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from chemenu import config
|
|
from chemenu.commands._util import fail, rel_path, success
|
|
from chemenu.frontmatter_io import write_page
|
|
from chemenu.page import Page
|
|
from chemenu.kb_scan import load_kb_pages
|
|
from chemenu.provenance import (
|
|
CITE_REF_RE,
|
|
cite_id,
|
|
render_page_body,
|
|
split_cite_block,
|
|
unique_cite_id,
|
|
)
|
|
|
|
app = typer.Typer(help="Manage [^cite-id] footnote citations and their Footnotes definition blocks.")
|
|
|
|
|
|
def _find_page(pages: dict[str, Page], title: str) -> Page:
|
|
if title not in pages:
|
|
fail(f"No page titled '{title}' found under wiki/.")
|
|
return pages[title]
|
|
|
|
|
|
@app.command("id")
|
|
def cite_id_command(
|
|
title: str = typer.Option(..., "--title", help="Source page title, e.g. 'Source - Docker Cheatsheet'"),
|
|
file: Optional[str] = typer.Option(None, "--file", help="Qualifier for a multi-file source, e.g. 'storage-model.md'"),
|
|
):
|
|
"""Print the deterministic id cite_id() would derive for (--title, --file).
|
|
|
|
Read-only preview - does not check the id is actually free on any given
|
|
page (two pages, or two distinct pairs on one page, can share this base
|
|
id; `cite add`/`cite sync` are what apply the real -2/-3 suffixing).
|
|
"""
|
|
typer.echo(cite_id(title, file))
|
|
|
|
|
|
def upsert_citation(page: Page, source_title: str, qualifier: Optional[str]) -> tuple[str, str, bool]:
|
|
"""Ensure `page` has a Footnotes definition for (source_title, qualifier)
|
|
and that source_title is in its frontmatter `sources:`. Returns
|
|
(cite_id_to_use, new_body, changed) - reuses an existing definition for
|
|
the same pair instead of minting a duplicate id."""
|
|
head, definitions = split_cite_block(page.body)
|
|
|
|
existing_id = next(
|
|
(cid for cid, pair in definitions.items() if pair == (source_title, qualifier)),
|
|
None,
|
|
)
|
|
if existing_id is not None:
|
|
marker_id = existing_id
|
|
block_changed = False
|
|
else:
|
|
marker_id = unique_cite_id(set(definitions), source_title, qualifier)
|
|
definitions[marker_id] = (source_title, qualifier)
|
|
block_changed = True
|
|
|
|
sources = page.frontmatter.setdefault("sources", [])
|
|
sources_changed = source_title not in sources
|
|
if sources_changed:
|
|
sources.append(source_title)
|
|
|
|
new_body = render_page_body(head, definitions)
|
|
changed = block_changed or sources_changed or new_body != page.body
|
|
return marker_id, new_body, changed
|
|
|
|
|
|
@app.command("add")
|
|
def cite_add(
|
|
page_title: str = typer.Option(..., "--page", help="Exact title of the page to add a citation on"),
|
|
source: str = typer.Option(..., "--source", help="Exact title of the source page being cited, e.g. 'Source - X'"),
|
|
file: Optional[str] = typer.Option(None, "--file", help="Qualifier for a multi-file source, e.g. 'storage-model.md'"),
|
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview instead of writing"),
|
|
):
|
|
"""Upsert a Footnotes definition for `--source` (reusing it if the page
|
|
already cites the same source/file pair) and ensure `--source` is in the
|
|
page's frontmatter `sources:`. Prints the `[^cite-id]` marker to paste
|
|
into the prose - placing it is still the caller's job.
|
|
"""
|
|
pages = load_kb_pages(config.KB_DIR)
|
|
page = _find_page(pages, page_title)
|
|
if source not in pages:
|
|
fail(f"No page titled '{source}' found under wiki/ - citing a page that doesn't exist would be a dangling reference.")
|
|
|
|
marker_id, new_body, changed = upsert_citation(page, source, file)
|
|
marker = f"[^{marker_id}]"
|
|
|
|
if dry_run:
|
|
state = "would update" if changed else "already up to date"
|
|
typer.echo(f"[dry-run] '{page_title}': {state}")
|
|
typer.echo(f"marker: {marker}")
|
|
typer.echo("No files written (--dry-run).")
|
|
return
|
|
|
|
if changed:
|
|
write_page(page.path, page.frontmatter, new_body)
|
|
typer.echo(f"marker: {marker}")
|
|
success(
|
|
f"{'Updated' if changed else 'Already up to date:'} '{page_title}' cites '{source}'"
|
|
+ (f" ({file})" if file else "")
|
|
+ f". Paste {marker} at the point in the prose the fact appears."
|
|
)
|
|
|
|
|
|
def sync_page(page: Page) -> tuple[str, bool, list[str], list[str]]:
|
|
"""Reconcile one page's Footnotes block against its actual `[^id]`
|
|
references: prune definitions nothing references any more, and re-render
|
|
the block in first-reference order. Never mints or recomputes an id from
|
|
a title - a reference with no definition is reported, not guessed at.
|
|
|
|
Returns (new_body, changed, pruned_ids, undefined_ref_ids).
|
|
"""
|
|
head, definitions = split_cite_block(page.body)
|
|
referenced_ids = [m.group(1) for m in CITE_REF_RE.finditer(head)]
|
|
referenced_set = set(referenced_ids)
|
|
|
|
if not definitions and not referenced_ids:
|
|
# No citation content at all - leave the page's whitespace exactly as
|
|
# it is. Without this, re-rendering an empty block still normalizes
|
|
# trailing newlines, which would make `cite sync --all` rewrite every
|
|
# page in the wiki instead of just the ones it actually has work to do.
|
|
return page.body, False, [], []
|
|
|
|
pruned = [cid for cid in definitions if cid not in referenced_set]
|
|
undefined = sorted({cid for cid in referenced_ids if cid not in definitions})
|
|
|
|
ordered: dict[str, tuple[str, Optional[str]]] = {}
|
|
seen: set[str] = set()
|
|
for cid in referenced_ids:
|
|
if cid in definitions and cid not in seen:
|
|
ordered[cid] = definitions[cid]
|
|
seen.add(cid)
|
|
|
|
new_body = render_page_body(head, ordered)
|
|
changed = new_body != page.body
|
|
return new_body, changed, pruned, undefined
|
|
|
|
|
|
@app.command("sync")
|
|
def cite_sync(
|
|
page_title: Optional[str] = typer.Option(None, "--page", help="Sync just this page"),
|
|
all_pages: bool = typer.Option(False, "--all", help="Sync every page under wiki/"),
|
|
dry_run: bool = typer.Option(False, "--dry-run", help="Report what would change instead of writing"),
|
|
):
|
|
"""Prune orphan Footnotes definitions and re-render each page's block in
|
|
first-reference order. Reports any `[^id]` reference left with no
|
|
definition - that is an editorial gap (a citation whose `cite add` never
|
|
ran, or a hand-typed id), not something this command can fix."""
|
|
if bool(page_title) == bool(all_pages):
|
|
fail("Provide exactly one of --page or --all")
|
|
|
|
pages = load_kb_pages(config.KB_DIR)
|
|
targets = [_find_page(pages, page_title)] if page_title else sorted(pages.values(), key=lambda p: p.path)
|
|
|
|
touched: list[str] = []
|
|
undefined_report: dict[str, list[str]] = {}
|
|
failed: list[str] = []
|
|
|
|
for page in targets:
|
|
new_body, changed, pruned, undefined = sync_page(page)
|
|
title = page.path.stem
|
|
if undefined:
|
|
undefined_report[title] = undefined
|
|
if not changed:
|
|
continue
|
|
touched.append(title)
|
|
if dry_run:
|
|
continue
|
|
try:
|
|
write_page(page.path, page.frontmatter, new_body)
|
|
except OSError as exc:
|
|
failed.append(f"{title} ({exc})")
|
|
|
|
if failed:
|
|
fail(
|
|
f"Synced {len(touched) - len(failed)}/{len(touched)} page(s) before a write failed: "
|
|
f"{', '.join(failed)}. Safe to retry - each page's re-render is idempotent."
|
|
)
|
|
|
|
verb = "Would update" if dry_run else "Updated"
|
|
if touched:
|
|
typer.echo(f"{verb} {len(touched)} page(s):")
|
|
for title in touched:
|
|
typer.echo(f" - {title}")
|
|
else:
|
|
typer.echo("No pages needed a Footnotes block change.")
|
|
|
|
if undefined_report:
|
|
typer.echo("")
|
|
typer.echo("Undefined [^id] reference(s) - run `cite add` for these, or fix the typo:")
|
|
for title, ids in undefined_report.items():
|
|
typer.echo(f" - {title}: {', '.join(ids)}")
|
|
|
|
if dry_run:
|
|
typer.echo("No files written (--dry-run).")
|
|
return
|
|
|
|
if not touched and not undefined_report:
|
|
success("Every Footnotes block already matches its page's references.")
|