Files
chemenu/tools/chemenu/commands/cite_cmd.py
T
torben 177c7e9ce8
CI / verify (push) Successful in 55s
Release / release (push) Successful in 38s
feat: Prosa ist kein Identifier - Link-Taxonomie als Enum, generierte Regionen mit Markern (4.0.0)
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
2026-09-02 18:39:22 +02:00

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.")