"""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[ \t]*)- \[(?P[ xX])\] (?P.*)$") MARKER_MUSTER = re.compile(r"\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} " 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 + [""]