18ae28f918
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki: raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren Grenzen in Code durchgesetzt sind statt im Prompt. Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44 Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist. Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs. Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte. Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet - siehe NOTICE.
221 lines
8.8 KiB
Python
221 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,
|
|
cite_block_heading,
|
|
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, cite_block_heading(page.body))
|
|
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, cite_block_heading(page.body))
|
|
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.")
|