Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur, was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen, das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor. Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale Bezeichnung ersetzt; die Regeln selbst sind unverändert. Co-Authored-By: Claude Opus 5 <[email protected]>
131 lines
4.4 KiB
Python
131 lines
4.4 KiB
Python
"""Liest und schreibt die todo.md eines Vault-Projekts."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
from .abgleich import Aufgabe
|
||
|
||
CHECKBOX_MUSTER = re.compile(r"^(?P<einrueckung>[ \t]*)- \[(?P<haken>[ xX])\] (?P<rest>.*)$")
|
||
MARKER_MUSTER = re.compile(r"\s*<!--\s*plane:(?P<marker>[A-Z0-9]+-\d+)\s*-->\s*$")
|
||
ABSCHNITT_AUS_PLANE = "## Aus Plane"
|
||
|
||
|
||
@dataclass
|
||
class TodoDatei:
|
||
pfad: Path
|
||
zeilen: list[str]
|
||
frontmatter: dict[str, str]
|
||
aufgaben: list[Aufgabe]
|
||
frontmatter_zeilen: int
|
||
|
||
|
||
def _lies_frontmatter(zeilen: list[str]) -> tuple[dict[str, str], int]:
|
||
"""Gibt die flachen Schlüssel-Wert-Paare und die Anzahl belegter Zeilen zurück."""
|
||
if not zeilen or zeilen[0].strip() != "---":
|
||
return {}, 0
|
||
for index in range(1, len(zeilen)):
|
||
if zeilen[index].strip() == "---":
|
||
werte: dict[str, str] = {}
|
||
for zeile in zeilen[1:index]:
|
||
schluessel, trenner, wert = zeile.partition(":")
|
||
if trenner:
|
||
werte[schluessel.strip()] = wert.strip()
|
||
return werte, index + 1
|
||
return {}, 0
|
||
|
||
|
||
def lies(pfad: Path) -> TodoDatei:
|
||
# utf-8-sig statt utf-8: ein BOM am Dateianfang wuerde sonst als in
|
||
# Zeile 0 stehen bleiben, das Frontmatter waere unsichtbar und der Skill
|
||
# meldete "plane_project fehlt", obwohl es dasteht. Windows-Editoren und
|
||
# PowerShell 5.1 schreiben UTF-8 regelmaessig mit BOM. Geschrieben wird
|
||
# weiterhin ohne BOM.
|
||
text = Path(pfad).read_text(encoding="utf-8-sig")
|
||
zeilen = text.split("\n")
|
||
frontmatter, belegt = _lies_frontmatter(zeilen)
|
||
|
||
aufgaben: list[Aufgabe] = []
|
||
for nummer, zeile in enumerate(zeilen):
|
||
if nummer < belegt:
|
||
continue
|
||
treffer = CHECKBOX_MUSTER.match(zeile)
|
||
if not treffer:
|
||
continue
|
||
rest = treffer.group("rest")
|
||
marker_treffer = MARKER_MUSTER.search(rest)
|
||
marker = marker_treffer.group("marker") if marker_treffer else None
|
||
text_ohne_marker = MARKER_MUSTER.sub("", rest).strip() if marker else rest.strip()
|
||
aufgaben.append(
|
||
Aufgabe(
|
||
zeilennummer=nummer,
|
||
text=text_ohne_marker,
|
||
erledigt=treffer.group("haken").lower() == "x",
|
||
marker=marker,
|
||
einrueckung=treffer.group("einrueckung"),
|
||
)
|
||
)
|
||
|
||
return TodoDatei(
|
||
pfad=Path(pfad),
|
||
zeilen=zeilen,
|
||
frontmatter=frontmatter,
|
||
aufgaben=aufgaben,
|
||
frontmatter_zeilen=belegt,
|
||
)
|
||
|
||
|
||
def plane_projekt(frontmatter: dict[str, str]) -> str | None:
|
||
wert = frontmatter.get("plane_project", "").strip()
|
||
return wert or None
|
||
|
||
|
||
def baue_zeile(einrueckung: str, text: str, erledigt: bool, marker: str | None) -> str:
|
||
haken = "x" if erledigt else " "
|
||
zeile = f"{einrueckung}- [{haken}] {text}"
|
||
if marker:
|
||
zeile = f"{zeile} <!-- plane:{marker} -->"
|
||
return zeile
|
||
|
||
|
||
def schreibe(
|
||
datei: TodoDatei,
|
||
aenderungen: dict[int, tuple[str, bool, str | None]],
|
||
neue_zeilen: list[str],
|
||
) -> str:
|
||
"""Baut den neuen Dateiinhalt. Unberührte Zeilen bleiben byteweise gleich."""
|
||
zeilen = list(datei.zeilen)
|
||
nach_nummer = {aufgabe.zeilennummer: aufgabe for aufgabe in datei.aufgaben}
|
||
|
||
for nummer, (text, erledigt, marker) in aenderungen.items():
|
||
alt = nach_nummer.get(nummer)
|
||
einrueckung = alt.einrueckung if alt else ""
|
||
zeilen[nummer] = baue_zeile(einrueckung, text, erledigt, marker)
|
||
|
||
if neue_zeilen:
|
||
zeilen = _haenge_an_aus_plane(zeilen, neue_zeilen)
|
||
|
||
return "\n".join(zeilen)
|
||
|
||
|
||
def _haenge_an_aus_plane(zeilen: list[str], neue_zeilen: list[str]) -> list[str]:
|
||
ergebnis = list(zeilen)
|
||
if ABSCHNITT_AUS_PLANE in [z.strip() for z in ergebnis]:
|
||
letzte = max(
|
||
i for i, z in enumerate(ergebnis) if z.strip() == ABSCHNITT_AUS_PLANE
|
||
)
|
||
einfuegen_ab = len(ergebnis)
|
||
for i in range(letzte + 1, len(ergebnis)):
|
||
if ergebnis[i].startswith("#"):
|
||
einfuegen_ab = i
|
||
break
|
||
while einfuegen_ab > letzte + 1 and not ergebnis[einfuegen_ab - 1].strip():
|
||
einfuegen_ab -= 1
|
||
return ergebnis[:einfuegen_ab] + neue_zeilen + ergebnis[einfuegen_ab:]
|
||
|
||
while ergebnis and not ergebnis[-1].strip():
|
||
ergebnis.pop()
|
||
return ergebnis + ["", ABSCHNITT_AUS_PLANE, ""] + neue_zeilen + [""]
|