"""`wikitool touch` - update the self-describing frontmatter fields of a page. `modified:`, `summary:` and `provenance:` describe the page itself rather than its relationships, so they were the one part of frontmatter the skills still told the LLM to edit by hand - a carve-out in the otherwise absolute "never hand-write frontmatter" rule. Bumping a date and rewriting a one-line summary are mechanical, so they belong here: the field name is chosen from the type's own schema (`modified` for entity/concept, `date` for source), and the result is schema-validated before it is written. Only `modified:` is bumped automatically. A source's `date:` is the publication date of the material itself, not a record of when we last edited the page, so it changes only on an explicit `--date`. """ from __future__ import annotations import datetime from typing import Any, Dict, Optional import typer # Hard, non-optional dependency - see type_resolver.py's import comment. from jsonschema import Draft202012Validator, FormatChecker from chemenu import config from chemenu.commands._util import ( check_raw_files_exist, fail, parse_set_fields, rel_path, success, ) from chemenu.frontmatter_io import normalize_dates from chemenu.frontmatter_io import write_page from chemenu.kb_scan import load_kb_pages from chemenu.type_resolver import resolver # Ordered by preference: whichever the page's schema declares is the one that # records "when was this page's content last confirmed?". DATE_FIELDS = ("modified", "date") # Fields `--set` refuses, each with the command that owns it instead. This is a # denylist rather than an allowlist on purpose: an allowlist is a second copy of # the schema, and the copy is the one that drifts - a field added to a type-spec # would silently stay unwritable until someone remembered to widen the list. # Everything the schema declares is settable unless there is a reason here. UNSETTABLE = { "type": ( "changing it changes the page's schema *and* the directory it belongs in - " "see instructions/page-lifecycle.md" ), "related": "page-reference field - use `wikitool xref add` / `xref remove`", "sources": ( "page-reference field - written from the other side by " "`wikitool xref link-source`, or cleared with `xref remove`" ), "entities": ( "page-reference field - use `wikitool xref link-source --source " "--entities `, which writes both directions; `xref remove` clears one" ), "concepts": ( "page-reference field - use `wikitool xref link-source --source " "--entities `, which writes both directions; `xref remove` clears one" ), } def _date_field(schema: Optional[Dict[str, Any]], frontmatter: Dict[str, Any]) -> Optional[str]: properties = (schema or {}).get("properties", {}) for field in DATE_FIELDS: if field in properties or field in frontmatter: return field return None def _parse_date(text: str) -> datetime.date: """`--date` as a real date, or a refusal naming the expected shape.""" try: return datetime.date.fromisoformat(text) except ValueError: fail(f"--date must be YYYY-MM-DD, got '{text}'.") def validate_fields( frontmatter: Dict[str, Any], schema: Optional[Dict[str, Any]], fields: set[str] ) -> Optional[str]: """Validate only the fields this command is writing. Whole-document validation would refuse to bump `modified:` on a page that is invalid for some unrelated, pre-existing reason - which is exactly the page most in need of maintenance. Errors whose path points outside the touched fields (missing required fields elsewhere, legacy extra keys) are left for `wikitool lint` to report. """ if schema is None: return None validator = Draft202012Validator(schema, format_checker=FormatChecker()) messages = [ error.message for error in validator.iter_errors(normalize_dates(frontmatter)) if error.path and error.path[0] in fields ] return "; ".join(messages) if messages else None def _settable_or_fail(field: str, schema: Optional[Dict[str, Any]], type_path: str) -> Dict[str, Any]: """Refuse a field this command must not write, and return its subschema. Two refusals, deliberately worded differently. A field on `UNSETTABLE` is writable in principle but belongs to another command, so the message names that command. A field the schema does not declare is not a routing problem but a typo or a wrong page type, so the message lists what this page actually has - the value is knowing that `tag` should have been `tags`. """ if field in UNSETTABLE: fail(f"`{field}` cannot be set with --set: {UNSETTABLE[field]}") properties = (schema or {}).get("properties", {}) if field not in properties: settable = sorted(set(properties) - set(UNSETTABLE)) fail( f"Type {type_path} declares no field '{field}'.\n" f" Settable fields for this page: {', '.join(settable) or '(none)'}" ) return properties[field] def _capture_field_or_fail(field: str, value: Any, frontmatter: Dict[str, Any]) -> None: """Refuse to overwrite a capture field (Gitea #67) that already carries a value - fill-once, not a denylist entry: `UNSETTABLE` would also forbid the *first* write, which is exactly the write the backfill needs. A capture field is fixed at `raw accept`/`new source` time; the only sanctioned way to change an already-set value is a new edition of the raw material (`raw accept --replaces`), never a second `touch`. """ current = frontmatter.get(field) if current not in (None, "") and current != value: fail( f"`{field}` is a capture field: fixed once, at `raw accept`/`new source` time, and " f"already reads {current!r}. A corrected capture is a new edition of the source, not " f"a touch -> `wikitool raw accept --replaces `." ) def _apply_set(frontmatter: Dict[str, Any], field: str, value: Any) -> Optional[str]: if frontmatter.get(field) == value: return None before = frontmatter.get(field) frontmatter[field] = value return f"{field}: {before!r} -> {value!r}" def _apply_add(frontmatter: Dict[str, Any], field: str, value: Any) -> Optional[str]: """Append list elements not already present, preserving order.""" if not isinstance(value, list): fail(f"--add works on array fields only; '{field}' is not one. Use --set.") current = list(frontmatter.get(field) or []) added = [item for item in value if item not in current] if not added: return None frontmatter[field] = current + added return f"{field}: added {', '.join(repr(i) for i in added)}" def _apply_remove(frontmatter: Dict[str, Any], field: str, value: Any) -> Optional[str]: """Drop list elements, reporting the ones that were not there. Removing something absent succeeds rather than failing - `xref remove` is idempotent for the same reason, and a repair command that refuses to run twice is a repair command nobody dares script. But it is *reported*: a silent no-op is how a mistyped element name looks exactly like a successful removal. """ if not isinstance(value, list): fail(f"--remove works on array fields only; '{field}' is not one. Use --set.") current = list(frontmatter.get(field) or []) present = [item for item in value if item in current] absent = [item for item in value if item not in current] if absent: typer.echo(f" {field}: not present, nothing removed: {', '.join(repr(i) for i in absent)}") if not present: return None frontmatter[field] = [item for item in current if item not in present] return f"{field}: removed {', '.join(repr(i) for i in present)}" def touch_command( page_title: str = typer.Option(..., "--page", help="Exact page title, e.g. 'Docker Cheatsheet'"), summary: Optional[str] = typer.Option(None, "--summary", help="Replace the page's 1-line summary"), provenance: Optional[str] = typer.Option( None, "--provenance", help="Replace the page's provenance marker (sourced|general|mixed)" ), date: Optional[str] = typer.Option( None, "--date", help="Date to record (YYYY-MM-DD). `modified:` defaults to today; a source's " "`date:` is its publication date and changes only when given here.", ), set_fields: Optional[list[str]] = typer.Option( None, "--set", help="Replace a frontmatter field, repeatable: --set tags=a,b. Array values split on " "commas (escape a literal one as \\,); repeating --set for one array field appends " "within this call. Page-reference fields belong to `xref`, not here", ), add_fields: Optional[list[str]] = typer.Option( None, "--add", help="Append elements to an array field without naming the whole list: --add tags=x. " "Elements already present are left alone", ), remove_fields: Optional[list[str]] = typer.Option( None, "--remove", help="Drop elements from an array field: --remove tags=x. Removing an absent element " "succeeds and says so", ), no_date: bool = typer.Option( False, "--no-date", help="Only change the given fields; leave the modified/date field alone" ), dry_run: bool = typer.Option(False, "--dry-run", help="Preview the new frontmatter instead of writing"), ): """Bump a page's `modified:` date and optionally rewrite its other frontmatter fields. `--summary`/`--provenance` are shorthands for the two fields worth their own flag; `--set`/`--add`/`--remove` reach every other field the page's type declares. Before they existed, a field `new` wrote once - `tags:`, `raw_files:` - could never be corrected: `touch` did not know it, hand-editing frontmatter is what the tool exists to prevent, and deleting the page to recreate it breaks every reference already pointing at it. A mistyped `--set tags=` at creation was therefore permanent, and `new` is not idempotent, so the window to get it right was exactly one command. """ pages = load_kb_pages(config.KB_DIR) page = pages.get(page_title) if page is None: fail(f"No page titled '{page_title}' found under kb/. Create it first with `wikitool new ...`.") type_path = page.frontmatter.get("type") if not type_path: fail(f"Page '{page_title}' has no `type:` frontmatter - fix it before touching it.") try: schema = resolver.get_schema(type_path, page.path) capture_fields = set(resolver.get_capture_fields(type_path, page.path)) except ValueError as exc: fail(str(exc)) frontmatter = dict(page.frontmatter) changes: list[str] = [] touched: set[str] = set() if not no_date: field = _date_field(schema, frontmatter) if field is None: fail(f"Type {type_path} declares no modified/date field - pass --no-date to skip it.") # `modified:` is ours to bump - it records when we last touched the page. # `date:` is not: on a source it is the source material's own publication # date, a fact about the world that today's date is simply wrong for. # Auto-bumping it silently replaced a raw file's real date with the day # the summary happened to be rewritten, and left the page contradicting # the `**Datum:**` line in its own body. It is still writable, but only # when the caller says so with an explicit `--date`. if field == "date" and date is None: touched.discard(field) else: # A `datetime.date`, not a string: that is what `yaml.safe_load` # yields for every page already on disk, and writing anything else # made an unchanged date compare unequal to itself - so `touch` # reported a change on every run, and `dump_frontmatter` had to # guess whether to quote what it was handed. new_date = _parse_date(date) if date else datetime.date.today() touched.add(field) if frontmatter.get(field) != new_date: frontmatter[field] = new_date changes.append(f"{field}: {page.frontmatter.get(field)} -> {new_date.isoformat()}") if summary is not None: frontmatter["summary"] = summary touched.add("summary") changes.append("summary updated") if provenance is not None: frontmatter["provenance"] = provenance touched.add("provenance") changes.append(f"provenance: {page.frontmatter.get('provenance')} -> {provenance}") # --set/--add/--remove last, so an explicit field always wins over the # shorthand flags rather than depending on option order. for flag, values, apply in ( ("--set", set_fields, _apply_set), ("--add", add_fields, _apply_add), ("--remove", remove_fields, _apply_remove), ): parsed = parse_set_fields(values, schema, flag=flag) for field, value in parsed.items(): _settable_or_fail(field, schema, type_path) if field in capture_fields: _capture_field_or_fail(field, value, frontmatter) change = apply(frontmatter, field, value) touched.add(field) if change: changes.append(change) # Filesystem check, not a data-shape one, so the schema cannot carry it - # and `touch` writes this field now, so it owes the same check `new` does. if "raw_files" in touched: check_raw_files_exist(frontmatter.get("raw_files")) error = validate_fields(frontmatter, schema, touched) if error: fail(f"Invalid value for type {type_path}: {error}") if not changes: success(f"'{page_title}' already up to date; nothing to change.") return for change in changes: typer.echo(f" {change}") if dry_run: typer.echo("No files written (--dry-run).") return write_page(page.path, frontmatter, page.body) success(f"Touched {rel_path(page.path)}")