"""§22.4a configurable collection metadata — sidecar storage + dual-read. SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md. Entry metadata is collection-configured and stored in a per-entry sidecar, `.meta.yaml`, with the `.md` body kept as pure prose (INV-2). This module is the storage/compat layer: - the **dual-read** parser (`read_entry`) — read the sidecar if present, else legacy top-of-document frontmatter, with identical resulting records (INV-6); - sidecar (de)serialization that preserves unknown / forward-compat keys (INV-7), reusing `entry`'s canonical field semantics; - lenient parsing that never hard-fails a read — a malformed sidecar surfaces a flag, not an exception (INV-3). The collection `fields:` schema and per-field validation are SLICE-2; faceted filtering and the edit UIs are later slices. This module interprets no field values — it only moves metadata between git and in-memory `Entry` records. """ from __future__ import annotations from dataclasses import dataclass from typing import Any import yaml from . import entry as entry_mod from .entry import Entry SIDECAR_SUFFIX = ".meta.yaml" # ----- filename helpers ----- def sidecar_name(slug: str) -> str: """The sidecar filename for an entry whose markdown is `.md`.""" return f"{slug}{SIDECAR_SUFFIX}" def is_sidecar(name: str) -> bool: return name.endswith(SIDECAR_SUFFIX) def slug_of_sidecar(name: str) -> str: """The entry stem for a `.meta.yaml` filename.""" return name[: -len(SIDECAR_SUFFIX)] if is_sidecar(name) else name def sidecar_path_for(md_path: str) -> str: """The sidecar path sibling to a `/.md` entry file.""" assert md_path.endswith(".md"), md_path return md_path[: -len(".md")] + SIDECAR_SUFFIX # ----- metadata <-> sidecar ----- def metadata_dict(entry: Entry) -> dict[str, Any]: """The full metadata mapping for an entry (known fields + `extra`).""" return entry_mod.to_frontmatter_dict(entry) def sidecar_yaml(entry: Entry) -> str: """Render an entry's metadata as standalone `.meta.yaml` text.""" return yaml.safe_dump( metadata_dict(entry), sort_keys=False, default_flow_style=False ) def parse_sidecar(text: str) -> tuple[dict[str, Any], bool]: """Parse sidecar YAML leniently → `(values, malformed)`. `malformed` is True when the text is not a YAML mapping (a list, a scalar, or a YAML syntax error). An empty / whitespace-only sidecar is an empty mapping, not malformed. Never raises (INV-3). """ try: raw = yaml.safe_load(text) except yaml.YAMLError: return {}, True if raw is None: return {}, False if not isinstance(raw, dict): return {}, True return raw, False # ----- frontmatter stripping (INV-2) ----- def strip_frontmatter(md_text: str) -> str: """Return the prose body of a `.md`, dropping a leading `---…---` block. A migrated entry's `.md` is body-only and passes through unchanged. A not-yet-migrated `.md` still carrying frontmatter yields just its body, so the dual-read body is the same either way. """ match = entry_mod.FRONTMATTER_RE.match(md_text) if not match: return md_text return match.group(2).lstrip("\n") # ----- dual-read (INV-6) ----- def read_entry( md_text: str, sidecar_text: str | None, *, fallback_slug: str | None = None ) -> tuple[Entry, bool]: """Read an entry from its `.md` and optional sidecar → `(Entry, malformed)`. - **Sidecar present and well-formed (non-empty):** metadata comes from the sidecar; the body is the `.md` stripped of any leading frontmatter. The sidecar is the source of truth (INV-1) and wins over stale `.md` frontmatter. - **Sidecar present but malformed:** the entry still loads from the legacy `.md` frontmatter (if any) and is flagged `malformed` (INV-3). - **Sidecar present but empty:** it has nothing to override with, so fall back to the `.md` frontmatter (not flagged). - **No sidecar:** the legacy path — parse the `.md` frontmatter (INV-6). `fallback_slug` (typically the filename stem) backstops the entry's slug whenever the metadata source lacks one — so a degenerate sidecar never yields a slug-less record the caller has to silently drop (INV-3). """ def _with_slug(entry: Entry) -> Entry: if not entry.slug and fallback_slug: entry.slug = fallback_slug return entry if sidecar_text is None: return _with_slug(entry_mod.parse(md_text)), False values, malformed = parse_sidecar(sidecar_text) if malformed or not values: # Malformed or empty sidecar: load from the legacy .md so the entry # still loads; flag only when the sidecar was actually malformed. try: entry = entry_mod.parse(md_text) except ValueError: entry = entry_mod.from_frontmatter({}, strip_frontmatter(md_text)) return _with_slug(entry), malformed body = strip_frontmatter(md_text) return _with_slug(entry_mod.from_frontmatter(values, body)), False # ----- value editing (SLICE-4) ----- def apply_values(entry: Entry, values: dict[str, Any]) -> Entry: """Return a new Entry with `values` merged over the entry's metadata. Known keys (`tags`, `state`, `reviewed_by`, …) land on their typed fields; unknown keys land on `extra` (INV-7). The body is carried through unchanged — this mutates metadata only. Unspecified keys are preserved. """ merged = metadata_dict(entry) merged.update(values) return entry_mod.from_frontmatter(merged, entry.body) # ----- git-aware read/write (SLICE-4) ----- @dataclass class EntryGitState: """An entry's on-disk state across its `.md` and optional sidecar. Captured by `read_entry_from_git` and consumed by `write_entry_files` to decide create-vs-update for the sidecar and whether the `.md` still needs its frontmatter stripped (lazy migration). """ entry: Entry md_text: str md_sha: str sidecar_text: str | None sidecar_sha: str | None malformed: bool async def read_entry_from_git( gitea: Any, org: str, repo: str, md_path: str, *, ref: str = "main" ) -> "EntryGitState | None": """Dual-read an entry from git → `EntryGitState`, or None if the `.md` is missing. Reads the `.md` and its sibling sidecar (if any); never raises on bad metadata (INV-3).""" md = await gitea.read_file(org, repo, md_path, ref=ref) if md is None: return None md_text, md_sha = md sc_path = sidecar_path_for(md_path) sc = await gitea.read_file(org, repo, sc_path, ref=ref) sidecar_text, sidecar_sha = (sc[0], sc[1]) if sc else (None, None) stem = md_path.rsplit("/", 1)[-1][: -len(".md")] entry, malformed = read_entry(md_text, sidecar_text, fallback_slug=stem) return EntryGitState( entry=entry, md_text=md_text, md_sha=md_sha, sidecar_text=sidecar_text, sidecar_sha=sidecar_sha, malformed=malformed, ) def _md_has_frontmatter(md_text: str) -> bool: return entry_mod.FRONTMATTER_RE.match(md_text) is not None def write_entry_files( md_path: str, entry: Entry, state: "EntryGitState" ) -> list[dict[str, Any]]: """Produce `change_files` ops that persist `entry`'s metadata to its sidecar and keep the `.md` as pure prose (INV-1/INV-2). - Sidecar: `create` when none existed, else `update` at its prior sha. - `.md`: rewritten body-only **only when it still carries frontmatter** (lazy migration, INV-6); an already-clean body is left untouched. """ sc_path = sidecar_path_for(md_path) ops: list[dict[str, Any]] = [] sc_op: dict[str, Any] = { "operation": "update" if state.sidecar_sha else "create", "path": sc_path, "content": sidecar_yaml(entry), } if state.sidecar_sha: sc_op["sha"] = state.sidecar_sha ops.append(sc_op) if _md_has_frontmatter(state.md_text): body = strip_frontmatter(state.md_text) new_md = body if (body == "" or body.endswith("\n")) else body + "\n" ops.append({ "operation": "update", "path": md_path, "content": new_md, "sha": state.md_sha, }) return ops # ----- frontmatter -> sidecar migration (PUC-5) ----- async def migrate_collection( gitea: Any, *, org: str, repo: str, subfolder: str = "", actor: Any = None, branch: str = "main", ) -> dict[str, Any]: """Migrate a collection's legacy-frontmatter entries to sidecars. Walks `/rfcs`; for each `.md` that has legacy frontmatter and **no** `.meta.yaml` sibling yet, it stages two file changes — create the sidecar (the entry's metadata, unknown keys preserved, INV-7) and rewrite the `.md` to body-only (INV-2) — and commits all of them in a single ChangeFiles commit (§6.5: one commit per collection). Idempotent: an entry that already has a sidecar is skipped; a second run with nothing left to migrate makes no commit. Returns `{"migrated": [...], "skipped": [...], "committed": bool}`. """ rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs" listing = await gitea.list_dir(org, repo, rfcs_dir, ref=branch) names = {f.get("name") for f in listing if f.get("type") == "file"} ops: list[dict[str, Any]] = [] migrated: list[str] = [] skipped: list[str] = [] for f in listing: if f.get("type") != "file" or not f.get("name", "").endswith(".md"): continue slug = f["name"][:-len(".md")] if sidecar_name(slug) in names: skipped.append(slug) # already migrated continue result = await gitea.read_file(org, repo, f["path"], ref=branch) if not result: continue text, sha = result try: e = entry_mod.parse(text) except ValueError: # No frontmatter to lift (e.g. an already-clean body without a # sidecar) — nothing to migrate; leave it untouched. skipped.append(slug) continue body = strip_frontmatter(text) new_md = body if (body == "" or body.endswith("\n")) else body + "\n" ops.append({ "operation": "create", "path": f"{rfcs_dir}/{sidecar_name(slug)}", "content": sidecar_yaml(e), }) ops.append({ "operation": "update", "path": f["path"], "content": new_md, "sha": sha, }) migrated.append(slug) committed = False if ops: n = len(migrated) message = f"Migrate {n} entr{'y' if n == 1 else 'ies'} to metadata sidecars (§22.4a)" kwargs: dict[str, Any] = {} if actor is not None: kwargs = { "author_name": actor.display_name, "author_email": actor.email or f"{actor.gitea_login}@users.noreply", } await gitea.change_files( org, repo, files=ops, message=message, branch=branch, **kwargs ) committed = True return {"migrated": migrated, "skipped": skipped, "committed": committed}