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
This commit is contained in:
1 parent
8e25a7865f
commit
9e414319b8
18 files changed
+615
-54
No files matched your search
@@ -12,7 +12,7 @@ from chemenu.commands._util import fail, rel_path, success, today_iso
|
||||
|
||||
app = typer.Typer(help="Manage wiki/log.md.")
|
||||
|
||||
VALID_OPS = ["ingest", "query", "lint", "create", "update", "delete", "rename"]
|
||||
VALID_OPS = ["ingest", "query", "lint", "create", "update", "delete", "rename", "move"]
|
||||
|
||||
# Matches the "## [YYYY-MM-DD] op | title" heading `format_log_entry` writes,
|
||||
# in file order (oldest first, since entries are appended).
|
||||
|
||||
@@ -378,7 +378,15 @@ def _paths_at(rev: str) -> Optional[list[str]]:
|
||||
|
||||
|
||||
def _shapes_at_revision(rev: str, wanted: set[str]) -> dict[str, corpus_diff.PageShape]:
|
||||
"""Page shapes as of `rev`, keyed by repo-relative path."""
|
||||
"""Page shapes as of `rev`, keyed by page title (the wiki's only identity
|
||||
for a page), not by path - a page that only moved directory between `rev`
|
||||
and now must compare as itself, not as one revision's delete plus the
|
||||
other's add. `PageShape.path` still carries the path, for `moved`.
|
||||
|
||||
Sorted path order, matching `kb_scan.load_kb_pages`: if two paths share a
|
||||
stem (a naming collision `lint` already reports as `duplicate_titles`),
|
||||
the later one wins here too, rather than raising mid-comparison.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
shapes: dict[str, corpus_diff.PageShape] = {}
|
||||
@@ -388,7 +396,7 @@ def _shapes_at_revision(rev: str, wanted: set[str]) -> dict[str, corpus_diff.Pag
|
||||
return shapes
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for relative in paths:
|
||||
for relative in sorted(paths):
|
||||
if wanted and not any(relative.startswith(prefix) for prefix in wanted):
|
||||
continue
|
||||
text = _git_show(rev, relative)
|
||||
@@ -403,13 +411,14 @@ def _shapes_at_revision(rev: str, wanted: set[str]) -> dict[str, corpus_diff.Pag
|
||||
frontmatter, body = read_page(scratch)
|
||||
except Exception: # noqa: BLE001 - an unparseable historical page is not this tool's error
|
||||
continue
|
||||
shapes[relative] = corpus_diff.PageShape.of(Page(Path(relative), frontmatter, body))
|
||||
shape = corpus_diff.PageShape.of(Page(Path(relative), frontmatter, body))
|
||||
shapes[shape.title] = shape
|
||||
return shapes
|
||||
|
||||
|
||||
def _shapes_now(wanted: set[str]) -> dict[str, corpus_diff.PageShape]:
|
||||
shapes: dict[str, corpus_diff.PageShape] = {}
|
||||
for path in kb_scan.iter_kb_pages(config.KB_DIR):
|
||||
for path in sorted(kb_scan.iter_kb_pages(config.KB_DIR)):
|
||||
relative = path.relative_to(config.ROOT).as_posix()
|
||||
if wanted and not any(relative.startswith(prefix) for prefix in wanted):
|
||||
continue
|
||||
@@ -417,7 +426,11 @@ def _shapes_now(wanted: set[str]) -> dict[str, corpus_diff.PageShape]:
|
||||
frontmatter, body = read_page(path)
|
||||
except Exception: # noqa: BLE001 - lint reports unreadable frontmatter
|
||||
continue
|
||||
shapes[relative] = corpus_diff.PageShape.of(Page(path, frontmatter, body))
|
||||
# The relative path, not `path` itself, so `PageShape.path` is
|
||||
# comparable to the historical side's - both repo-relative - and a
|
||||
# `moved` entry names a path rather than this machine's tmp_path.
|
||||
shape = corpus_diff.PageShape.of(Page(Path(relative), frontmatter, body))
|
||||
shapes[shape.title] = shape
|
||||
return shapes
|
||||
|
||||
|
||||
@@ -439,6 +452,11 @@ def verify_command(
|
||||
must not change: wikilink and citation *counts*, footnote definitions, H1,
|
||||
and structural frontmatter.
|
||||
|
||||
Pages are matched by title, not path, so a page that only moved directory
|
||||
(see `wikitool move`) compares as itself rather than as a
|
||||
removed-and-added pair - its path change is reported separately, as
|
||||
`moved`, and never counted as a finding on its own.
|
||||
|
||||
Not migration-specific - worth running after any bulk rewrite. `lint` cannot
|
||||
answer this: it reads one revision, so a reference that went missing is
|
||||
invisible to it."""
|
||||
@@ -455,6 +473,9 @@ def verify_command(
|
||||
"compared": diff.compared,
|
||||
"added": diff.added,
|
||||
"removed": diff.removed,
|
||||
"moved": [
|
||||
{"page": title, "from": old, "to": new} for title, old, new in diff.moved
|
||||
],
|
||||
"findings": [
|
||||
{"path": f.path, "kind": f.kind, "detail": f.detail} for f in diff.findings
|
||||
],
|
||||
|
||||
@@ -194,46 +194,29 @@ def _apply_template_variables(template: str, variables: Dict[str, Any]) -> str:
|
||||
|
||||
def _page_subdir(subtype: Optional[str], type_path: str) -> Optional[str]:
|
||||
"""Return the subtype-driven subdirectory under a type's `base_dir`, from
|
||||
the type-spec's own `layout:` frontmatter. Returns None for types with no
|
||||
`layout:` (flat directory). Falls back to `<subtype>s` for a subtype the
|
||||
layout doesn't list, matching the previous hand-maintained behavior."""
|
||||
if subtype is None:
|
||||
return None
|
||||
try:
|
||||
layout = resolver.get_layout(type_path)
|
||||
except ValueError:
|
||||
layout = None
|
||||
if layout is None:
|
||||
return None
|
||||
return layout.get(subtype, {}).get("dir", subtype + "s")
|
||||
the type-spec's own `layout:` frontmatter. Thin wrapper over
|
||||
`TypeResolver.subtype_dir` - the one place this computation lives, shared
|
||||
with `move` and `lint`'s misplaced-page finding."""
|
||||
return resolver.subtype_dir(type_path, subtype)
|
||||
|
||||
|
||||
def _target_dir(type_path: str, frontmatter: Dict[str, Any]) -> Path:
|
||||
"""Resolve where an instance of this type is written: `<root>/<base_dir>`,
|
||||
plus a subtype subdirectory when the type declares a `layout:`.
|
||||
|
||||
Thin wrapper over `TypeResolver.compute_target_dir` - the one placement
|
||||
rule, also used by `move` and `lint`'s misplaced-page finding -
|
||||
converting its `ValueError` into the CLI's normal friendly-failure path.
|
||||
|
||||
`base_dir` is resolved against `config.KB_DIR` by default, so tests that
|
||||
point KB_DIR at a temporary fixture wiki can never write into the real
|
||||
`kb/`. A type-spec declaring `root: repo` resolves against `config.ROOT`
|
||||
instead - for artifacts that are agent-directed material rather than
|
||||
knowledge, and so live outside the knowledge layer."""
|
||||
base_dir = resolver.get_base_dir(type_path)
|
||||
if not base_dir:
|
||||
fail(
|
||||
f"Type {type_path} declares no `base_dir:` and cannot be "
|
||||
f"instantiated as a page"
|
||||
)
|
||||
try:
|
||||
root = resolver.get_root(type_path)
|
||||
return resolver.compute_target_dir(type_path, frontmatter)
|
||||
except ValueError as exc:
|
||||
fail(str(exc))
|
||||
target = (config.ROOT if root == "repo" else config.KB_DIR) / base_dir
|
||||
subtype_field = resolver.get_subtype_field(type_path)
|
||||
if subtype_field:
|
||||
subdir = _page_subdir(frontmatter.get(subtype_field), type_path)
|
||||
if subdir:
|
||||
target = target / subdir
|
||||
return target
|
||||
|
||||
|
||||
def _validate_or_fail(frontmatter: Dict[str, Any], type_path: str, source_dir: Path) -> None:
|
||||
|
||||
@@ -28,6 +28,7 @@ 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 (
|
||||
@@ -378,3 +379,100 @@ def rm_command(
|
||||
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.")
|
||||
Reference in new issue
Block a user