Files changed: - CHANGES.md - VERSION - tools/CONTRACT.md - tools/chemenu/commands/cite_cmd.py - tools/chemenu/commands/links_cmd.py - tools/chemenu/commands/xref.py
128 lines
4.8 KiB
Python
128 lines
4.8 KiB
Python
"""`wikitool links` - the declared graph around one page, both directions.
|
|
|
|
The half that makes authored directional edges liveable. An edge is written once,
|
|
on the page that asserts it, so the question "what points at *this* page" has no
|
|
answer stored anywhere - it is computed from the graph, which is the only way it
|
|
is ever complete. A mirrored edge only ever recorded what someone remembered to
|
|
mirror.
|
|
|
|
Read-only, and exempt from the iteration budget for the same reason `search` is:
|
|
it answers a question rather than changing anything, and an agent that has to
|
|
ration looking things up starts guessing instead.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json as _json
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from chemenu import cli_contract, config, links
|
|
from chemenu.commands._util import console, fail
|
|
from chemenu.kb_scan import load_kb_pages
|
|
from chemenu.page import Page
|
|
|
|
app = typer.Typer(help="Show the declared edges into and out of a page.")
|
|
|
|
EDGE_FIELD = "related"
|
|
|
|
|
|
def _collection_of(page: Page) -> Optional[str]:
|
|
try:
|
|
return page.path.relative_to(config.KB_DIR).parts[0]
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
def outbound(pages: dict[str, Page], title: str) -> list[dict]:
|
|
"""Edges this page asserts, in file order."""
|
|
page = pages[title]
|
|
return [
|
|
{"target": edge.target, "label": edge.label, "resolves": edge.target in pages}
|
|
for edge in links.edges(page.frontmatter, EDGE_FIELD)
|
|
]
|
|
|
|
|
|
def inbound(pages: dict[str, Page], title: str) -> list[dict]:
|
|
"""Edges other pages assert *about* this one.
|
|
|
|
A full scan of the corpus rather than a stored list, deliberately: the whole
|
|
argument for dropping mirrored edges is that this answer is derived and
|
|
therefore cannot go stale or be half-written.
|
|
"""
|
|
found = [
|
|
{"source": other, "label": edge.label, "collection": _collection_of(page)}
|
|
for other, page in pages.items()
|
|
for edge in links.edges(page.frontmatter, EDGE_FIELD)
|
|
if edge.target == title
|
|
]
|
|
return sorted(found, key=lambda item: (item["label"] or "", item["source"]))
|
|
|
|
|
|
@app.command("show")
|
|
@cli_contract.record(cli_contract.CommandRecord(
|
|
path="links show",
|
|
summary="Show the edges out of and into a page.",
|
|
synopsis=(cli_contract.Variant(usage='links show --page "<Title>" [--json]'),),
|
|
properties=cli_contract.Properties(
|
|
effect=cli_contract.Effect.READ,
|
|
idempotent=cli_contract.Idempotent.YES,
|
|
atomic="Read-only",
|
|
budget=cli_contract.Budget.EXEMPT,
|
|
),
|
|
notes=(
|
|
"Shows the declared graph around one page in both directions: the edges it asserts "
|
|
"(from its own `related:`, with labels) and the edges other pages assert about it.",
|
|
"The inbound half is computed across the corpus on every call, never stored, so it is "
|
|
"complete.",
|
|
"`--json` prints both halves as JSON.",
|
|
"Read-only; exempt from the Iteration Budget Gate.",
|
|
),
|
|
failures=(cli_contract.Failure(
|
|
cause="Page not found",
|
|
reaction="Check the exact title with `search`; a wikilink target is not always the "
|
|
"page's stem",
|
|
),),
|
|
examples=(
|
|
'tools/wikitool links show --page "Act Runner"',
|
|
'tools/wikitool links show --page "Act Runner" --json',
|
|
),
|
|
see_also=(
|
|
"`wikitool xref add` / `wikitool xref remove` - change the outbound edges",
|
|
"`wikitool search` - finds the exact title",
|
|
),
|
|
))
|
|
def links_show(
|
|
page: str = typer.Option(..., "--page", help="Exact page title"),
|
|
json_out: bool = typer.Option(False, "--json", help="Print the edges as JSON"),
|
|
):
|
|
"""Show the edges out of and into a page.
|
|
|
|
Outbound is what the page declares in `related:`. Inbound is computed across
|
|
the corpus - nothing stores it, which is exactly why it is complete."""
|
|
pages = load_kb_pages(config.KB_DIR)
|
|
if page not in pages:
|
|
fail(f"No page titled '{page}' found under kb/.")
|
|
|
|
out, back = outbound(pages, page), inbound(pages, page)
|
|
|
|
if json_out:
|
|
typer.echo(_json.dumps({"page": page, "outbound": out, "inbound": back}, indent=2))
|
|
return
|
|
|
|
console.print(f"[bold]{page}[/bold]")
|
|
console.print(f"\n[cyan]asserts ({len(out)})[/cyan]")
|
|
if not out:
|
|
console.print(" (none)")
|
|
for edge in out:
|
|
label = edge["label"] or "[dim]unlabelled[/dim]"
|
|
missing = "" if edge["resolves"] else " [red](no such page)[/red]"
|
|
console.print(f" {label} -> [[{edge['target']}]]{missing}")
|
|
|
|
console.print(f"\n[cyan]asserted about it ({len(back)})[/cyan]")
|
|
if not back:
|
|
console.print(" (none - nothing in the corpus declares an edge to this page)")
|
|
for edge in back:
|
|
label = edge["label"] or "[dim]unlabelled[/dim]"
|
|
console.print(f" [[{edge['source']}]] {label} ->")
|