"""Liest YAML-Frontmatter und schreibt einzelne Felder zurück, ohne den Rest anzufassen.""" from __future__ import annotations import re from dataclasses import dataclass from pathlib import Path TRENNER = "---" SKALARE_ZEILE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$") @dataclass(frozen=True) class Notiz: pfad: Path frontmatter: dict[str, str] rumpf: str roh: str def _zerlege(roh: str) -> tuple[list[str], str]: """Liefert (Frontmatter-Zeilen ohne Trenner, Rumpf). Ohne Frontmatter: ([], roh).""" if not roh.startswith(TRENNER + "\n"): return [], roh rest = roh[len(TRENNER) + 1 :] ende = rest.find("\n" + TRENNER + "\n") if ende == -1: return [], roh kopf = rest[:ende].split("\n") rumpf = rest[ende + len(TRENNER) + 2 :] return kopf, rumpf.lstrip("\n") def _entpacke(wert: str) -> str: wert = wert.strip() if len(wert) >= 2 and wert[0] == wert[-1] and wert[0] in "\"'": return wert[1:-1] return wert def _verpacke(wert: str) -> str: """Quotet nur, wo YAML es wirklich braucht. Ein Doppelpunkt allein reicht nicht: „sha256:abc" und „2026-08-05T12:00:00+02:00" sind gültige einfache Skalare. Erst „Doppelpunkt plus Leerzeichen" beendet einen Schlüssel. Würde hier jeder Doppelpunkt quoten, stünde in jeder Notiz bookstack_hash: "sha256:…" statt bookstack_hash: sha256:… """ if not wert: return '""' unsicher = ( ": " in wert or wert.endswith(":") or " #" in wert or "\n" in wert or wert != wert.strip() or wert[0] in "\"'[]{}>|*&!%@`#" ) if unsicher: return '"' + wert.replace('"', '\\"') + '"' return wert def lade_notiz(pfad: Path) -> Notiz: roh = pfad.read_text(encoding="utf-8") kopf, rumpf = _zerlege(roh) felder: dict[str, str] = {} for zeile in kopf: treffer = SKALARE_ZEILE.match(zeile) if treffer and not treffer.group(2).startswith("["): felder[treffer.group(1)] = _entpacke(treffer.group(2)) return Notiz(pfad=pfad, frontmatter=felder, rumpf=rumpf, roh=roh) def setze_felder(roh: str, werte: dict[str, str]) -> str: """Setzt oder ersetzt Frontmatter-Felder und lässt alles andere zeichengleich.""" kopf, rumpf = _zerlege(roh) if not kopf and not roh.startswith(TRENNER + "\n"): neue = [f"{name}: {_verpacke(wert)}" for name, wert in werte.items()] return TRENNER + "\n" + "\n".join(neue) + "\n" + TRENNER + "\n" + roh offen = dict(werte) ergebnis: list[str] = [] for zeile in kopf: treffer = SKALARE_ZEILE.match(zeile) name = treffer.group(1) if treffer else None if name in offen: ergebnis.append(f"{name}: {_verpacke(offen.pop(name))}") else: ergebnis.append(zeile) ergebnis.extend(f"{name}: {_verpacke(wert)}" for name, wert in offen.items()) return TRENNER + "\n" + "\n".join(ergebnis) + "\n" + TRENNER + "\n\n" + rumpf def schreibe_felder(pfad: Path, werte: dict[str, str]) -> None: roh = pfad.read_text(encoding="utf-8") pfad.write_text(setze_felder(roh, werte), encoding="utf-8", newline="\n")