Files
planer2/backend/planung.py
2026-07-29 15:19:41 +02:00

164 lines
5.5 KiB
Python

"""Eine Planung: der Kartensatz, wie er nach der nächsten Umsetzung aussehen soll.
Die Übersicht beantwortet „was kann mein Projekt". Eine geplante Funktion gehört
dort nicht hin — sie wäre gelogen. Deshalb liegt die Planung in einer eigenen
Kopie und der Projektordner bleibt unberührt, bis sie umgesetzt wird.
Kopiert wird der ganze Ordner, nicht nur die Karten. Gespräch und
Urteilsgedächtnis gehören zur Planung: was während des Planens besprochen und
beurteilt wurde, gilt für den geplanten Stand, nicht für den heutigen.
Solange eine Planung läuft, arbeitet `projekte.wurzel()` in der Kopie. Keine
Schreibfunktion muss davon wissen.
Es gibt höchstens eine Planung je Projekt. Sie endet auf zwei Arten: umgesetzt —
dann ersetzt die Kopie den Projektstand — oder abgebrochen, dann verschwindet sie
spurlos.
"""
import shutil
import parser
import projekte
# Was beim Vergleich zählt: der sichtbare Inhalt einer Funktion, nicht ihre Datei.
# Wird eine Karte umbenannt, wandert ihre Datei — der Vergleich hinge sonst an
# einem Dateinamen statt an dem, was dasteht.
FELDER = ("ziel", "regeln", "testen", "stand")
def laeuft(projekt: str) -> bool:
return (projekte.stand(projekt) / projekte.PLANUNG).is_dir()
def _kopie(projekt: str):
return projekte.stand(projekt) / projekte.PLANUNG
def starte(projekt: str):
"""Arbeitskopie anlegen. Ab hier zeigt `wurzel()` dorthin."""
ziel = _kopie(projekt)
if ziel.is_dir():
raise ValueError("Für dieses Projekt läuft schon eine Planung")
shutil.copytree(projekte.stand(projekt), ziel,
ignore=shutil.ignore_patterns(projekte.PLANUNG))
def brich_ab(projekt: str):
"""Planung verwerfen. Der Projektstand hat sie nie gesehen."""
shutil.rmtree(_kopie(projekt), ignore_errors=True)
def setze_um(projekt: str):
"""Die Kopie wird der neue Projektstand.
Erst die Kopie herausheben, dann den alten Stand räumen, dann die Kopie an
seine Stelle. Ein Absturz mitten im Räumen ließe sonst ein Projekt ohne Karten
zurück — und die Planung gleich mit.
"""
kopie = _kopie(projekt)
if not kopie.is_dir():
raise ValueError("Für dieses Projekt läuft keine Planung")
ordner = projekte.stand(projekt)
daneben = ordner.parent / f"{ordner.name}.neu"
shutil.rmtree(daneben, ignore_errors=True)
kopie.rename(daneben)
for eintrag in ordner.iterdir():
shutil.rmtree(eintrag) if eintrag.is_dir() else eintrag.unlink()
for eintrag in daneben.iterdir():
eintrag.rename(ordner / eintrag.name)
daneben.rmdir()
def _karten(ordner) -> dict:
"""titel -> Sektionen. Verglichen wird über den Titel, nicht über die Datei."""
raus = {}
for datei in sorted(ordner.glob("*.md")):
s = parser.zerlege(datei.read_text())
raus[s["titel"]] = s
return raus
def _funktionstext(f: dict) -> str:
return "\n".join(str(f.get(feld) or "") for feld in FELDER)
def _funktionen(karte: dict) -> dict:
return {f["text"]: _funktionstext(f) for f in karte["funktionen"]}
def _kartentext(karte: dict) -> str:
return "\n".join((karte.get("art") or "", karte.get("ziel") or "",
karte.get("regeln") or ""))
def aenderungen(projekt: str) -> dict:
"""Was die Planung am Projektstand ändert — je Karte und je Funktion.
Ohne laufende Planung ist das leer. Die Übersicht zeigt dann nichts an, und
die Karten sehen aus wie immer.
"""
if not laeuft(projekt):
return {}
alt = _karten(projekte.stand(projekt))
neu = _karten(_kopie(projekt))
raus = {}
for titel in set(alt) | set(neu):
a, b = alt.get(titel), neu.get(titel)
if a is None:
raus[titel] = {"art": "neu", "funktionen": {
f["text"]: "neu" for f in b["funktionen"]}}
continue
if b is None:
raus[titel] = {"art": "weg", "funktionen": {
f["text"]: "weg" for f in a["funktionen"]}}
continue
fa, fb = _funktionen(a), _funktionen(b)
funktionen = {}
for name in set(fa) | set(fb):
if name not in fa:
funktionen[name] = "neu"
elif name not in fb:
funktionen[name] = "weg"
elif fa[name] != fb[name]:
funktionen[name] = "geaendert"
kopf = _kartentext(a) != _kartentext(b)
if funktionen or kopf:
raus[titel] = {"art": "geaendert", "funktionen": funktionen,
"kopf": kopf}
return raus
def _datei_zu(ordner, titel: str):
for datei in ordner.glob("*.md"):
if parser.zerlege(datei.read_text())["titel"] == titel:
return datei
return None
def zuruecksetzen(projekt: str, titel: str):
"""Eine Karte auf den Projektstand zurückholen.
Drei Fälle: neu dazugekommen (löschen), weggefallen (zurückholen), geändert
(überschreiben). Der Rest der Planung bleibt stehen.
"""
if not laeuft(projekt):
raise ValueError("Für dieses Projekt läuft keine Planung")
ordner, kopie = projekte.stand(projekt), _kopie(projekt)
urspruenglich = _datei_zu(ordner, titel)
geplant = _datei_zu(kopie, titel)
if urspruenglich is None and geplant is None:
raise FileNotFoundError(f"Karte „{titel}“ nicht gefunden")
if urspruenglich is None:
geplant.unlink()
return
ziel = geplant or (kopie / urspruenglich.name)
ziel.write_text(urspruenglich.read_text())
def stand(projekt: str) -> dict:
return {"laeuft": laeuft(projekt), "aenderungen": aenderungen(projekt)}