- .vault-tools/frontmatter-migration/migriere.py - AI/Regelwerk/frontmatter.md Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py. Co-Authored-By: Claude Opus 5 <[email protected]>
319 lines
13 KiB
Python
319 lines
13 KiB
Python
#!/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, netz-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", "netz-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/<projekt>/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())
|