Files
chemenu/tools/chemenu/commands/page_ops.py
T
torben 9e414319b8
CI / verify (push) Successful in 58s
Release / release (push) Successful in 35s
feat: wikitool move - eine kb-Seite folgt ihrem Subtype ins berechnete Verzeichnis (#56)
Files changed:
- CHANGES.md
- VERSION
- instructions/page-lifecycle.md
- instructions/publish-cycle.md
- tools/CONTRACT.md
- tools/chemenu/cli.py
- tools/chemenu/commands/log_append.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/corpus_diff.py
- tools/chemenu/lint_core.py
- tools/chemenu/tests/test_corpus_diff.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_page_ops.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/type_resolver.py
2026-09-04 22:50:22 +02:00

479 lines
19 KiB
Python

"""`wikitool rename` / `wikitool rm` - the two page mutations that had no command.
A page's title is the wiki's only identifier for it, so renaming or deleting a
page is never just a filesystem operation: the title appears in every other
page's body `[[wikilinks]]`, in the `[[Title]]` a `[^cite-id]` footnote
definition points at, and in page-reference frontmatter arrays (`related:`,
`sources:`, `entities:`, `concepts:`).
Doing this by hand is what left four pages citing
`Source - Docker Cheatsheet.md` when the page is
`Source - Docker Cheatsheet` - and because `lint`'s broken-link check
only walked page bodies, nothing ever reported it.
Which frontmatter fields hold page titles comes from each type-spec's
`page_ref_fields:`, so a new type needs no change here.
Neither command is atomic: both write one page at a time. Both are idempotent
per page, so a retry after a partial failure is safe.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Optional
import typer
from chemenu import config, links
from chemenu.commands._util import check_collision, fail, rel_path, success
from chemenu.frontmatter_io import write_page
from chemenu.lint_core import find_misplaced
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,
)
from chemenu.type_resolver import resolver
# `[[Target]]`, `[[Target|alias]]`, `[[Target#anchor]]` - including the
# `[[Target]]` inside a `[^cite-id]: [[Target]]` Footnotes definition, which
# is exactly what lets retarget_body() repoint a citation's link target on a
# rename. Group 1 is the target title; group 2 keeps any alias/anchor suffix
# untouched. The id itself is a separate concern - see retarget_cite_ids().
LINK_RE = re.compile(r"\[\[([^\[\]|#]+)((?:[|#][^\[\]]*)?)\]\]")
def page_ref_fields(page: Page) -> list[str]:
"""The page's declared page-title frontmatter fields, or [] if its type
can't be resolved (lint reports that separately)."""
type_path = page.frontmatter.get("type")
if not type_path:
return []
try:
return resolver.get_page_ref_fields(type_path, page.path)
except ValueError:
return []
def retarget_body(body: str, old: str, new: str) -> str:
"""Repoint every wikilink and citation marker aimed at `old` to `new`,
preserving any `|alias` or `#anchor` suffix."""
def replace(match: re.Match) -> str:
target, suffix = match.group(1), match.group(2)
if target.strip() != old:
return match.group(0)
return f"[[{new}{suffix}]]"
return LINK_RE.sub(replace, body)
def retarget_cite_ids(body: str, old: str, new: str) -> str:
"""After retarget_body() has already repointed a Footnotes definition's
`[[old]]` link target to `[[new]]`, also refresh a citation id that was
*derived* from `old`'s slug - `[^s-old-title]` -> `[^s-new-title]` - in
both the definition and every inline `[^id]` reference to it.
An id not derived from `old` (hand-picked, or a `-2`/`-3` collision
suffix from an unrelated pair) is left untouched; body is returned
unchanged if nothing needs renaming.
"""
head, definitions = split_cite_block(body)
if not definitions:
return body
renames: dict[str, str] = {}
new_definitions: dict[str, tuple[str, Optional[str]]] = {}
reserved = set(definitions)
for cid, (title, qualifier) in definitions.items():
if title == new and cid == cite_id(old, qualifier):
new_id = unique_cite_id(reserved - {cid}, new, qualifier)
renames[cid] = new_id
reserved.add(new_id)
new_definitions[new_id] = (title, qualifier)
else:
new_definitions[cid] = (title, qualifier)
if not renames:
return body
new_head = CITE_REF_RE.sub(lambda m: f"[^{renames.get(m.group(1), m.group(1))}]", head)
return render_page_body(new_head, new_definitions)
def retarget_frontmatter(page: Page, old: str, new: str) -> bool:
"""Repoint `old` to `new` in every declared page-ref field. Returns True if
anything changed."""
changed = False
for field in page_ref_fields(page):
values = page.frontmatter.get(field)
if not values:
continue
# Through `links` so a labelled edge keeps its label across a rename:
# the entry is `{label: target}`, and a plain equality swap would have
# compared the mapping against a title and silently left it pointing at
# the old page.
if links.retarget(page.frontmatter, field, old, new):
changed = True
return changed
def _ref_fields_to_sweep(page: Page) -> list[str]:
"""Every field this page might hold a reference in.
The type's own declaration, plus any field already present on the page that
*some* type declares as a reference field. The second half exists because a
command can leave a reference in a field this type does not declare - `xref
add` wrote `related:` on source pages until 1.6.0 - and clearing exactly
that kind of leftover is what `xref remove` promises to be for. Sweeping
only declared fields made the state unreachable.
The extra names come from the type-specs rather than a constant here, so a
new reference field is swept without a code change.
"""
declared = page_ref_fields(page)
known = {
field
for _path, frontmatter in resolver.list_type_specs()
for field in (frontmatter.get("page_ref_fields") or [])
}
extra = [f for f in page.frontmatter if f not in declared and f in known]
return declared + extra
def strip_frontmatter_ref(page: Page, title: str) -> bool:
"""Drop `title` from every page-ref field. Returns True if anything changed.
An *undeclared* field that ends up empty is removed outright rather than
left as `field: []`: it was never valid for this type, and leaving the key
keeps the page failing schema validation for a reference that is gone.
"""
changed = False
declared = page_ref_fields(page)
for field in _ref_fields_to_sweep(page):
values = page.frontmatter.get(field)
if not values:
continue
before = list(values)
links.remove(page.frontmatter, field, title)
updated = page.frontmatter.get(field) or []
if updated == before:
continue
if not updated and field not in declared:
del page.frontmatter[field]
else:
page.frontmatter[field] = updated
changed = True
return changed
def strip_link_bullets(body: str, title: str) -> str:
"""Remove whole-line list bullets that exist only to point at `title` -
`- [[Title]]` (See Also) and `- **label:** [[Title]]` (Relationships).
Deliberately narrow: a bullet carrying prose alongside the link, and a
`[^cite-id]: [[Title]]` Footnotes definition line (which never starts
with `-`, so the pattern below cannot match it), are left alone. Removing
a citation is an editorial judgment about a claim, not a mechanical
de-linking.
"""
escaped = re.escape(title)
pattern = re.compile(
rf"^[ \t]*-[ \t]+(?:\*\*[^*\n]+:\*\*[ \t]+)?\[\[{escaped}\]\][ \t]*\n?",
re.MULTILINE,
)
return pattern.sub("", body)
def body_references(body: str, title: str) -> int:
"""How many wikilinks in `body` still point at `title`."""
return sum(1 for match in LINK_RE.finditer(body) if match.group(1).strip() == title)
def inbound_pages(pages: dict[str, Page], title: str) -> list[str]:
"""Every page (other than `title` itself) referencing it from its body or
from a declared page-ref frontmatter field."""
found = set()
for other_title, page in pages.items():
if other_title == title:
continue
if body_references(page.body, title):
found.add(other_title)
continue
if any(title in (page.frontmatter.get(f) or []) for f in page_ref_fields(page)):
found.add(other_title)
return sorted(found)
def rename_command(
old: str = typer.Option(..., "--from", help="Current page title, exactly as it appears"),
new: str = typer.Option(..., "--to", help="New page title"),
dry_run: bool = typer.Option(False, "--dry-run", help="List what would change instead of writing"),
):
"""Rename a page, or repoint references that name a page that never existed.
Two modes, chosen by whether `--from` is an actual page:
- `--from` is a page: it is renamed to `--to` (which must be free) and every
reference follows.
- `--from` is not a page but is referenced: references are repointed to
`--to`, which must already exist. This is the cleanup case - a reference
spelled `act_runner` when the page is `Act Runner`, or
`Source - X.md` when the page is `Source - X`. Nothing moves on disk.
"""
if old == new:
fail("--from and --to are the same title; nothing to rename.")
pages = load_kb_pages(config.KB_DIR)
target = pages.get(old)
references_only = target is None
if references_only:
if new not in pages:
fail(
f"Neither '{old}' nor '{new}' is a page under wiki/. Repointing references "
f"to '{new}' would just move the dangling reference; create the page first "
"with `wikitool new ...`, or drop the reference with `wikitool xref remove`."
)
elif not dry_run:
check_collision(new)
elif new in pages:
fail(f"A page titled '{new}' already exists at {rel_path(pages[new].path)}")
touched: list[str] = []
failed: list[str] = []
for title, page in sorted(pages.items()):
new_body = retarget_body(page.body, old, new)
new_body = retarget_cite_ids(new_body, old, new)
if title == old and page.h1_title == old:
new_body = re.sub(rf"^# {re.escape(old)}$", f"# {new}", new_body, count=1, flags=re.MULTILINE)
frontmatter_changed = retarget_frontmatter(page, old, new)
if new_body == page.body and not frontmatter_changed:
continue
touched.append(title)
if not dry_run:
try:
write_page(page.path, page.frontmatter, new_body)
except OSError as exc:
failed.append(f"{title} ({exc})")
if failed:
fail(
f"Updated references in {len(touched) - len(failed)}/{len(touched)} page(s) before a write "
f"failed: {', '.join(failed)}. Nothing was renamed on disk, so '{old}' is unchanged - check "
"`git status`, resolve the write failure (permissions/disk), then re-run the full `rename` "
"command (safe to retry - each page's rewrite is idempotent)."
)
for title in touched:
typer.echo(f" updated references in '{title}'")
if references_only:
if not touched:
success(f"Nothing references '{old}'; nothing to repoint.")
return
if dry_run:
typer.echo(f"[dry-run] would repoint {len(touched)} page(s) to '{new}'. No files written.")
return
success(
f"Repointed references from '{old}' to the existing page '{new}' in "
f"{len(touched)} page(s). No file was moved ('{old}' was not a page)."
)
return
new_path = target.path.parent / f"{new}.md"
if dry_run:
typer.echo(f"[dry-run] would rename {rel_path(target.path)} -> {rel_path(new_path)}")
typer.echo(f"[dry-run] would update {len(touched)} page(s). No files written.")
return
target.path.rename(new_path)
success(
f"Renamed '{old}' -> '{new}' ({rel_path(new_path)}); "
f"updated references in {len(touched)} page(s). "
"Run `wikitool index rebuild` and `wikitool sources rebuild-index` next."
)
def rm_command(
page_title: str = typer.Option(..., "--page", help="Exact title of the page to delete"),
yes: bool = typer.Option(
False,
"--yes",
"-y",
help="Confirm deletion of a page that other pages still reference. Only pass this after "
"a human has reviewed the inbound list - never set it automatically.",
),
dry_run: bool = typer.Option(False, "--dry-run", help="List what would change instead of writing"),
):
"""Delete a page and mechanically de-link it from the rest of the wiki."""
pages = load_kb_pages(config.KB_DIR)
target = pages.get(page_title)
if target is None:
fail(f"No page titled '{page_title}' found under wiki/.")
inbound = inbound_pages(pages, page_title)
if inbound and not yes:
listed = "\n".join(f"- {t}" for t in inbound)
fail(
f"'{page_title}' is still referenced by {len(inbound)} page(s). Deleting it will leave "
"their prose pointing at nothing. Show the user this list and only re-run with --yes "
f"once they have approved:\n{listed}"
)
touched: list[str] = []
failed: list[str] = []
leftover: list[tuple[str, int]] = []
for title, page in sorted(pages.items()):
if title == page_title:
continue
new_body = strip_link_bullets(page.body, page_title)
frontmatter_changed = strip_frontmatter_ref(page, page_title)
if new_body != page.body or frontmatter_changed:
touched.append(title)
if not dry_run:
try:
write_page(page.path, page.frontmatter, new_body)
except OSError as exc:
failed.append(f"{title} ({exc})")
remaining = body_references(new_body, page_title)
if remaining:
leftover.append((title, remaining))
if failed:
fail(
f"De-linked {len(touched) - len(failed)}/{len(touched)} page(s) before a write failed: "
f"{', '.join(failed)}. '{page_title}' was NOT deleted, so nothing is orphaned - check "
"`git status`, resolve the write failure, then re-run `rm` (safe to retry)."
)
for title in touched:
typer.echo(f" de-linked '{title}'")
if dry_run:
typer.echo(f"[dry-run] would delete {rel_path(target.path)}")
typer.echo(f"[dry-run] would update {len(touched)} page(s). No files written.")
else:
target.path.unlink()
if leftover:
typer.echo("")
typer.echo(
"Prose references left in place - these carry claims, so removing them is an "
"editorial call, not a mechanical one:"
)
for title, count in leftover:
typer.echo(f" - {title}: {count} remaining [[{page_title}]] reference(s)")
typer.echo("Fix them, then re-run `wikitool lint`.")
if dry_run:
return
success(
f"Deleted '{page_title}' ({rel_path(target.path)}); de-linked {len(touched)} page(s). "
"Run `wikitool index rebuild` and `wikitool sources rebuild-index` next."
)
def move_command(
page_title: Optional[str] = typer.Option(
None, "--page", help="Exact title of the page to move to its computed location"
),
reconcile: bool = typer.Option(
False, "--reconcile", help="Move every page under wiki/ that is not at its computed location"
),
dry_run: bool = typer.Option(False, "--dry-run", help="List what would move without writing"),
):
"""Move a page (or every misplaced page) to the directory its type-spec
computes for its current frontmatter - `base_dir` + `layout`, the same
rule `new` places a page by when it is first created.
The destination is never chosen by hand: there is no `--to <dir>`. Only
the file moves - no body, no frontmatter field, and the page's title (its
only identity in the wiki) never changes.
"""
if bool(page_title) == bool(reconcile):
fail('Pass exactly one of --page "<Title>" or --reconcile.')
pages = load_kb_pages(config.KB_DIR)
if reconcile:
candidates = find_misplaced(pages)
if not candidates:
success("Nothing to move; every page is already at its computed location.")
return
planned: list[tuple[str, Page, Path, Path]] = []
collisions: list[str] = []
for title, page, target_dir in candidates:
new_path = target_dir / f"{title}.md"
if new_path.exists():
collisions.append(f"{title} (target {rel_path(new_path)} already exists)")
continue
planned.append((title, page, target_dir, new_path))
if dry_run:
for title, page, _target_dir, new_path in planned:
typer.echo(f"[dry-run] would move {rel_path(page.path)} -> {rel_path(new_path)}")
if collisions:
typer.echo("")
typer.echo("Skipped (target already exists) - resolve with `wikitool rename` first:")
for collision in collisions:
typer.echo(f" - {collision}")
typer.echo(f"[dry-run] would move {len(planned)} page(s). No files written.")
return
moved: list[str] = []
failed: list[str] = list(collisions)
for title, page, target_dir, new_path in planned:
try:
target_dir.mkdir(parents=True, exist_ok=True)
page.path.rename(new_path)
except OSError as exc:
failed.append(f"{title} ({exc})")
continue
moved.append(title)
typer.echo(f" moved '{title}' -> {rel_path(new_path)}")
if failed:
fail(
f"Moved {len(moved)}/{len(candidates)} page(s) before a failure: {', '.join(failed)}. "
"Safe to retry - `move --reconcile` only re-moves what is still misplaced."
)
success(f"Moved {len(moved)} page(s). Run `wikitool index rebuild` next.")
return
target = pages.get(page_title)
if target is None:
fail(f"No page titled '{page_title}' found under wiki/.")
type_path = target.frontmatter.get("type")
if not type_path:
fail(f"'{page_title}' has no `type:` field, so no placement can be computed for it.")
try:
target_dir = resolver.compute_target_dir(type_path, target.frontmatter, target.path)
except ValueError as exc:
fail(str(exc))
if target_dir.resolve() == target.path.parent.resolve():
success(f"'{page_title}' is already at its computed location ({rel_path(target.path)}); nothing to move.")
return
new_path = target_dir / f"{page_title}.md"
if new_path.exists():
fail(f"Cannot move '{page_title}': {rel_path(new_path)} already exists.")
if dry_run:
typer.echo(f"[dry-run] would move {rel_path(target.path)} -> {rel_path(new_path)}")
return
target_dir.mkdir(parents=True, exist_ok=True)
target.path.rename(new_path)
success(f"Moved '{page_title}' -> {rel_path(new_path)}. Run `wikitool index rebuild` next.")