#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Zieht den Vault auf das Frontmatter-Vokabular aus AI/Regelwerk/frontmatter.md. Ersteller: Pascal Michel Erstellt: 2026-09-03 Drei Dinge, in dieser Reihenfolge: 1 Synonyme umbenennen (projekt -> project, task/aufgaben -> todo, ...) 2 fehlende type-Werte je Dateiart ergaenzen 3 fehlende status-Werte aus dem Standard des Typs ergaenzen Vorgabe der Werte: AI/Regelwerk/frontmatter.md. Die Tabellen unten sind die maschinenlesbare Fassung davon - aendert sich die Regel, aendert sich hier. AUSGENOMMEN, bewusst: * IT Workspace/ - Artikel mit alter Tag-Konvention, siehe navigation.md * CLAUDE.md - der Stamm ist keine Notiz * MEMORY.md - Verzeichnisse, keine Inhalte * die Spezialbereiche isms-*, bookstack-*, iam-projekt bleiben unberuehrt Ohne --anwenden passiert nichts. Der Trockenlauf ist die Voreinstellung. Aufruf: python .vault-tools/frontmatter-migration/migriere.py --vault . python .vault-tools/frontmatter-migration/migriere.py --vault . --anwenden """ import argparse import datetime as _dt import io import os import re import sys import traceback TOOLKIT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "obsidian-vault-navigator", "scripts") # ---- Vorgaben aus AI/Regelwerk/frontmatter.md ------------------------------ UMBENENNEN = { "projekt": "project", "task": "todo", "aufgaben": "todo", "project-todo": "todo", "anleitung": "guide", "checklist": "guide", "architecture": "codedoku", "arbeitsdokument": "note", "reference": "memory", # betraf ausschliesslich Gedaechtnisdumps } STATUS_UMBENENNEN = {"approved": "done", "abgeloest": "done"} STANDARD_STATUS = { "moc": "reference", "daily": "open", "session": "done", "memory": "reference", "note": "active", "konzept": "draft", "resource": "reference", "guide": "active", "project": "active", "spec": "draft", "plan": "active", "plan-task": "draft", "todo": "active", "lessons": "active", "changelog": "active", "codedoku": "active", } SPEZIAL = {"isms-dokument", "isms-kapitel", "bookstack-seite", "bookstack-kapitel", "bookstack-buchbeschreibung", "iam-projekt"} AUSGENOMMEN_ORDNER = ( "IT Workspace/", # alte Tag-Konvention, siehe navigation.md "Bookstack/", # Staging mit eigener #review-Konvention ".claude/", # Skills und Agenten des Harness ) # Diese Pfadbestandteile schliessen aus, egal wo sie stehen. Grund: Es sind # KEINE Vault-Notizen, sondern Code-Artefakte mit eigenem Schema. Ein # type: note in einer SKILL.md kann den Skill-Lader stoeren, und die Dateien # unter tools/testdaten/ sind Prueffaelle - wer sie anfasst, faelscht Tests. AUSGENOMMEN_TEIL = ("/skill/", "/skills/", "/plugin/", "/tools/testdaten/") AUSGENOMMEN_DATEI = ("CLAUDE.md",) AUSGENOMMEN_NAME = ("MEMORY.md", "SKILL.md") # Importierte Normen- und Referenzvolltexte. Fuer sie taugt die maschinelle # Ableitung nicht: der Titel waere der Dateiname, die Zusammenfassung eine # Seitenzahl aus dem Amtsblatt. Deshalb hier von Hand, an einer Stelle. HANDARBEIT = { "Security - Compliance Projekte/active - BSI/C52026en.md": dict( typ="resource", status="reference", titel="BSI C5:2026 — Cloud Computing Compliance Criteria Catalogue", summary="Volltext des BSI-Kriterienkatalogs C5:2026 mit allen 174 Kriterien, englische Fassung. Grundlage des Nachweis-Mappings in den Compliance-Projekten."), "Security - Compliance Projekte/active - NIS2/NIS2_Richtlinie_EU_Recht.md": dict( typ="resource", status="reference", titel="NIS2-Richtlinie (EU) 2022/2555 — Volltext", summary="Volltext der NIS2-Richtlinie aus dem Amtsblatt der Europaeischen Union. Referenzmaterial, noch ohne eigene Auswertung."), "Security - Compliance Projekte/active - NIST/NIST_Richtlinie.md": dict( typ="resource", status="reference", titel="NIST Cybersecurity Framework 2.0 — Volltext", summary="Volltext des NIST Cybersecurity Framework in der Fassung vom 2024-02-26. Referenzmaterial, noch ohne eigene Auswertung."), "Security - Compliance Projekte/active - ISO27001 - 9001/DIN_EN_ISO_9001-2015.md": dict( typ="resource", status="reference", titel="DIN EN ISO 9001:2015 — Volltext", summary="Volltext der DIN EN ISO 9001:2015. Achtung: Die ISO 27001 liegt trotz Ordnername nicht vor."), "Security - Compliance Projekte/active - HIPAA/hipaa201303.md": dict( typ="resource", status="reference", titel="HIPAA — Omnibus Final Rule 2013, Volltext", summary="Volltext der HIPAA-Regelung in der Fassung von 2013. Referenzmaterial ohne eigene Auswertung."), } def _yaml(wert): """YAML-sicherer Skalar. Doppelpunkte werden gequotet, nicht ersetzt - 'C5:2026' zu 'C5 -2026' zu verstuemmeln war der erste, falsche Versuch.""" wert = " ".join(str(wert).split()) if any(z in wert for z in ':#[]{}&*!|>%@`"') or wert.startswith(("-", "?")): return '"%s"' % wert.replace('"', "'") return wert def typ_aus_pfad(rel): """Typ je Dateiart. Reihenfolge zaehlt: spezifisch vor allgemein.""" name = os.path.basename(rel) if rel.startswith("AI/sessions/"): return "session" if rel.startswith("AI/secondbrain/"): return "memory" if rel.startswith("AI/Regelwerk/"): return "resource" if rel.startswith("Inbox/") and re.match(r"^\d{4}-\d{2}-\d{2}\.md$", name): return "daily" if name.startswith("_MOC-") or name == "Wissenslandkarte.md": return "moc" if "/docs/superpowers/specs/" in rel: return "spec" if "/docs/code/" in rel: return "codedoku" if "/docs/plans/" in rel: # Planordner mit mehreren Tasks: Einzeldateien sind plan-task rest = rel.split("/docs/plans/", 1)[1] return "plan-task" if "/" in rest else "plan" if name == "README.md": # Nur direkt im Projektordner: "Code Projekte//README.md". # Tiefer liegende README-Dateien beschreiben ein Unterverzeichnis. return "project" if rel.count("/") == 2 else "note" if name == "todo.md": return "todo" if name == "CHANGELOG.md": return "changelog" if name == "lessons.md": return "lessons" if name == "Administrator-Anleitung.md": return "guide" return "note" # ---- Mechanik -------------------------------------------------------------- FM_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL) FELD_RE = re.compile(r"^(\w+):\s*(.*)$") REIHENFOLGE = ("uid", "type", "title", "status", "summary", "tags", "created", "updated") def ausgenommen(rel): if rel in AUSGENOMMEN_DATEI or os.path.basename(rel) in AUSGENOMMEN_NAME: return True if any(rel.startswith(o) for o in AUSGENOMMEN_ORDNER): return True return any(teil in "/" + rel for teil in AUSGENOMMEN_TEIL) def lies_block(text): m = FM_RE.match(text) if not m: return None, text zeilen = m.group(1).split("\n") return zeilen, text[m.end():] def feld(zeilen, name): for z in zeilen: m = FELD_RE.match(z) if m and m.group(1) == name: return m.group(2).strip() return "" def setze(zeilen, name, wert): """Setzt oder ergaenzt ein Feld und haelt die Reihenfolge ein.""" for i, z in enumerate(zeilen): m = FELD_RE.match(z) if m and m.group(1) == name: zeilen[i] = "%s: %s" % (name, wert) return zeilen # neu einsortieren pos = REIHENFOLGE.index(name) if name in REIHENFOLGE else len(REIHENFOLGE) einfuege = len(zeilen) for i, z in enumerate(zeilen): m = FELD_RE.match(z) if not m: continue p = REIHENFOLGE.index(m.group(1)) if m.group(1) in REIHENFOLGE else len(REIHENFOLGE) if p > pos: einfuege = i break zeilen.insert(einfuege, "%s: %s" % (name, wert)) return zeilen def main(): p = argparse.ArgumentParser(description="Frontmatter auf das Vokabular ziehen") p.add_argument("--vault", default=".") p.add_argument("--anwenden", action="store_true") p.add_argument("--neu-anlegen", dest="neu_anlegen", action="store_true", help="auch Dateien ganz ohne Frontmatter versehen") args = p.parse_args() vault = os.path.abspath(os.path.expanduser(args.vault)) if TOOLKIT not in sys.path: sys.path.insert(0, TOOLKIT) try: import vaultlib as V except ImportError as exc: sys.stderr.write("ABBRUCH: vaultlib nicht importierbar (%s)\n" % exc) return 2 aenderungen = [] try: for ap, rel in V.walk_vault(vault): if not rel.endswith(".md") or ausgenommen(rel): continue text = io.open(ap, encoding="utf-8").read() zeilen, rumpf = lies_block(text) if zeilen is None: if not args.neu_anlegen: continue # Vollstaendiges Frontmatter anlegen. Titel und Zusammenfassung # werden mit den Funktionen des Toolkits ABGELEITET, nicht # erfunden; uid und Daten aus der Dateizeit. hand = HANDARBEIT.get(rel) if hand: typ = hand["typ"] titel = hand["titel"] zusammenfassung = hand["summary"] else: typ = typ_aus_pfad(rel) titel = V.derive_title({}, text, rel) zusammenfassung = V.derive_summary(text) or titel datum = _dt.date.fromtimestamp(os.path.getmtime(ap)).isoformat() kennung = "%s-%s" % (datum.replace("-", ""), V.slugify(titel)[:40]) neue = [ "uid: %s" % kennung, "type: %s" % typ, 'title: %s' % _yaml(titel), "status: %s" % (hand["status"] if hand else STANDARD_STATUS.get(typ, "active")), 'summary: %s' % _yaml(zusammenfassung[:300]), "tags: [%s]" % typ, "created: %s" % datum, "updated: %s" % datum, ] neu_text = "---\n" + "\n".join(neue) + "\n---\n\n" + text probe, probe_rumpf = lies_block(neu_text) if probe is None or probe_rumpf.lstrip("\n") != text.lstrip("\n"): raise RuntimeError("Anlegen veraendert den Inhalt: %s" % rel) aenderungen.append((ap, rel, ["Frontmatter angelegt (%s)" % typ], neu_text)) continue alt_typ = feld(zeilen, "type") if alt_typ in SPEZIAL: continue was = [] neu_typ = UMBENENNEN.get(alt_typ, alt_typ) if not neu_typ: neu_typ = typ_aus_pfad(rel) was.append("type fehlte -> %s" % neu_typ) elif neu_typ != alt_typ: was.append("type %s -> %s" % (alt_typ, neu_typ)) alt_st = feld(zeilen, "status") neu_st = STATUS_UMBENENNEN.get(alt_st, alt_st) if not neu_st: neu_st = STANDARD_STATUS.get(neu_typ, "active") was.append("status fehlte -> %s" % neu_st) elif neu_st != alt_st: was.append("status %s -> %s" % (alt_st, neu_st)) if not was: continue zeilen = setze(list(zeilen), "type", neu_typ) zeilen = setze(zeilen, "status", neu_st) neu_text = "---\n" + "\n".join(zeilen) + "\n---\n" + rumpf # Selbstkontrolle vor dem Schreiben: Der Rumpf muss Zeichen fuer # Zeichen derselbe sein, und das neue Frontmatter muss die # gesetzten Werte auch wirklich tragen. Sonst ist es ein Befund. probe_zeilen, probe_rumpf = lies_block(neu_text) if probe_rumpf != rumpf: raise RuntimeError("Rumpf veraendert: %s" % rel) if probe_zeilen is None: raise RuntimeError("Frontmatter nicht mehr lesbar: %s" % rel) if feld(probe_zeilen, "type") != neu_typ or feld(probe_zeilen, "status") != neu_st: raise RuntimeError("Werte nicht angekommen: %s" % rel) aenderungen.append((ap, rel, was, neu_text)) except Exception: sys.stdout.write("# ABGEBROCHEN\n\n" + traceback.format_exc()) sys.stdout.write("\nBis dahin gesammelt: %d Aenderung(en)\n" % len(aenderungen)) return 2 sys.stdout.write("# Frontmatter-Migration%s\n\n" % ("" if args.anwenden else " (TROCKENLAUF - nichts wird geschrieben)")) sys.stdout.write("%d Datei(en) betroffen\n\n" % len(aenderungen)) for _, rel, was, _ in aenderungen: sys.stdout.write(" - %-78s %s\n" % (rel[:78], "; ".join(was))) if args.anwenden: for ap, rel, _, neu_text in aenderungen: io.open(ap, "w", encoding="utf-8", newline="\n").write(neu_text) sys.stdout.write("\n%d Datei(en) geschrieben.\n" % len(aenderungen)) return 0 if __name__ == "__main__": sys.exit(main())