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
381 lines
15 KiB
Python
381 lines
15 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.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."
|
|
)
|