From 1b6899638b818bd92fddf8e54efa984d0f6979a4 Mon Sep 17 00:00:00 2001 From: team3 Date: Mon, 5 Oct 2026 17:01:40 +0200 Subject: [PATCH] Versionen je Thema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Thema hat Versionen storage//v/ mit eigenem Lauf, Buch und Leseposition; bestehende Ablagen wandern beim Start nach v1/. Intern ist „Thema/vN“ der Schlüssel aller Zustandsfunktionen. API: ?v=N an allen Themen-Endpunkten, POST /version, DELETE ?v=N. Oberfläche: Versionsliste unter dem Thema (Klick auf Thema → Lauf der neuesten, auf Version → Buch), Knopf „Neue Version“ mit gleichen Eingaben. make loeschen kennt V=. Co-Authored-By: Claude Fable 5.1 --- API.md | 10 +++ Makefile | 13 +-- backend/main.py | 110 ++++++++++++++++---------- backend/state.py | 89 ++++++++++++++++----- frontend/src/App.vue | 61 ++++++++++---- frontend/src/api.js | 22 +++--- frontend/src/components/Guide.vue | 18 ++--- frontend/src/components/Lauf.vue | 4 +- frontend/src/components/Lesemodus.vue | 6 +- frontend/src/style.css | 3 + 10 files changed, 229 insertions(+), 107 deletions(-) diff --git a/API.md b/API.md index 08e732a..234cb08 100644 --- a/API.md +++ b/API.md @@ -79,3 +79,13 @@ storage// berichte/ .md (Abschlussberichte) log.jsonl Ereignisse ``` + +## Versionen + +Ein Thema kann mehrere Versionen haben (gleiche Eingaben, unabhängiger Lauf, eigenes Buch und eigene Leseposition). +Ablage: `storage//thema.json` und `storage//v/…`. Alte Ablagen werden beim Start nach `v1/` verschoben. + +- `GET /api/topics` liefert je Thema `versionen: [{v, status, laeuft, stufe, angelegt, kosten_usd}]`; die Felder `status/laeuft/stufe` gelten für die neueste Version. +- `POST /api/topics/{name}/version` → `{"v": N}` legt die nächste Version an (ohne Start). +- Alle Endpunkte unter `/api/topics/{name}/…` nehmen `?v=N`; ohne Angabe gilt die neueste Version. +- `DELETE /api/topics/{name}?v=N` löscht nur diese Version, ohne `v` das ganze Thema. diff --git a/Makefile b/Makefile index b2ca5be..57a5665 100644 --- a/Makefile +++ b/Makefile @@ -50,10 +50,11 @@ pull: @echo "Geholt: $(SERVER):$(REMOTE)/$(SYNC_QUELLE) → $(SYNC_QUELLE)" # Fertige Bücher lassen sich in der Oberfläche nicht löschen. Nur hier, mit Rückfrage: -# make loeschen TOPIC="Godot 4" – entfernt das Thema auf dem Server (über die API) und lokal. +# make loeschen TOPIC="Godot 4" – ganzes Thema mit allen Versionen (Server über die API, dann lokal) +# make loeschen TOPIC="Godot 4" V=2 – nur Version 2 loeschen: - @[ -n "$(TOPIC)" ] || { echo "TOPIC= angeben"; exit 1; } - @printf 'Thema „%s“ samt Buch auf dem Server und lokal unwiderruflich löschen? [j/N] ' "$(TOPIC)"; read a; [ "$$a" = "j" ] - curl -fsS -X DELETE "$(URL)/api/topics/$$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$(TOPIC)")" && echo - rm -rf "storage/$(TOPIC)" - @echo "Gelöscht: $(TOPIC)" + @[ -n "$(TOPIC)" ] || { echo "TOPIC= angeben (optional V=)"; exit 1; } + @printf '%s auf dem Server und lokal unwiderruflich löschen? [j/N] ' "$(if $(V),Version $(V) von „$(TOPIC)“,Thema „$(TOPIC)“ mit allen Versionen)"; read a; [ "$$a" = "j" ] + curl -fsS -X DELETE "$(URL)/api/topics/$$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$(TOPIC)")$(if $(V),?v=$(V),)" && echo + rm -rf "storage/$(TOPIC)$(if $(V),/v$(V),)" + @echo "Gelöscht: $(TOPIC)$(if $(V), v$(V),)" diff --git a/backend/main.py b/backend/main.py index 2905e10..ad8054b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -16,6 +16,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name @asynccontextmanager async def lifespan(app: FastAPI): config.STORAGE.mkdir(exist_ok=True) + state.migrieren() yield for name in list(pipeline._laeufe): await pipeline.pausieren(name) @@ -35,19 +36,34 @@ class Position(BaseModel): lesemodus: str | int | None = None # neu: key des Bildschirms, alt: Index -def _pruefen(name: str) -> dict: - if not state.name_ok(name) or not (t := state.thema(name)): +def _key(name: str, v: int | None = None) -> str: + """Themenname + optionale Version → Schlüssel „Thema/vN“ (ohne v: neueste).""" + if not state.name_ok(name) or not state.thema(name): raise HTTPException(404, "Thema unbekannt") - return t + key = state.schluessel(name, v) + if not key: + raise HTTPException(404, "Version unbekannt") + return key + + +def _version_info(name: str, n: int) -> dict: + key = f"{name}/v{n}" + st = state.zustand(key) + angelegt = st.get("angelegt") or (state.thema(name) or {}).get("angelegt", "") # migrierte v1 hat kein eigenes Datum + return {"v": n, "status": st["status"], "laeuft": pipeline.laeuft(key), "stufe": st["stufe"], + "angelegt": angelegt, "kosten_usd": st.get("kosten_usd", 0)} @app.get("/api/topics") async def topics(): aus = [] for t in state.themen(): - st = state.zustand(t["name"]) - aus.append({"name": t["name"], "titel": t["titel"], "status": st["status"], - "laeuft": pipeline.laeuft(t["name"]), "stufe": st["stufe"]}) + vs = [_version_info(t["name"], n) for n in state.versionen(t["name"])] + if not vs: + continue + neueste = vs[-1] + aus.append({"name": t["name"], "titel": t["titel"], "status": neueste["status"], + "laeuft": neueste["laeuft"], "stufe": neueste["stufe"], "versionen": vs}) return aus @@ -62,77 +78,87 @@ async def topic_anlegen(body: ThemaNeu): raise HTTPException(409, str(e)) +@app.post("/api/topics/{name}/version") +async def version_anlegen(name: str): + """Neue Version: gleiche Eingaben, frischer Lauf, ohne Wissen über die Vorgänger.""" + _key(name) + key = state.version_anlegen(name) + return {"v": int(key.rsplit("/v", 1)[1])} + + @app.delete("/api/topics/{name}") -async def topic_loeschen(name: str): - _pruefen(name) - await pipeline.pausieren(name) - state.loeschen(name) +async def topic_loeschen(name: str, v: int | None = None): + if v is None: + _key(name) + for n in state.versionen(name): + await pipeline.pausieren(f"{name}/v{n}") + state.loeschen(name) + else: + key = _key(name, v) + await pipeline.pausieren(key) + state.version_loeschen(key) return {"ok": True} @app.post("/api/topics/{name}/start") -async def start(name: str): - _pruefen(name) - if state.zustand(name)["status"] == "fertig": +async def start(name: str, v: int | None = None): + key = _key(name, v) + if state.zustand(key)["status"] == "fertig": raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen") - pipeline.starten(name) + pipeline.starten(key) return {"ok": True} @app.post("/api/topics/{name}/pause") -async def pause(name: str): - _pruefen(name) - await pipeline.pausieren(name) +async def pause(name: str, v: int | None = None): + await pipeline.pausieren(_key(name, v)) return {"ok": True} @app.post("/api/topics/{name}/reset") -async def reset(name: str): - _pruefen(name) - await pipeline.pausieren(name) - state.zuruecksetzen(name) +async def reset(name: str, v: int | None = None): + key = _key(name, v) + await pipeline.pausieren(key) + state.zuruecksetzen(key) return {"ok": True} @app.get("/api/topics/{name}/state") -async def zustand(name: str): - t = _pruefen(name) - st = state.zustand(name) - return {"name": name, "titel": t["titel"], "hinweise": t.get("hinweise", ""), **st, - "deutung": st.get("deutung") or state.deutung(name), "ereignisse": st["ereignisse"][-100:]} +async def zustand(name: str, v: int | None = None): + key = _key(name, v) + t = state.thema(name) + st = state.zustand(key) + return {"name": name, "version": int(key.rsplit("/v", 1)[1]), "titel": t["titel"], "hinweise": t.get("hinweise", ""), **st, + "deutung": st.get("deutung") or state.deutung(key), "ereignisse": st["ereignisse"][-100:]} @app.get("/api/topics/{name}/position") -async def position_lesen(name: str): - _pruefen(name) - return state.position(name) +async def position_lesen(name: str, v: int | None = None): + return state.position(_key(name, v)) @app.put("/api/topics/{name}/position") -async def position_schreiben(name: str, body: Position): - _pruefen(name) - return state.position_setzen(name, {k: v for k, v in body.model_dump().items() if v is not None}) +async def position_schreiben(name: str, body: Position, v: int | None = None): + return state.position_setzen(_key(name, v), {k: val for k, val in body.model_dump().items() if val is not None}) @app.get("/api/topics/{name}/guide") -async def guide(name: str): - _pruefen(name) - return montage.guide(name) +async def guide(name: str, v: int | None = None): + return montage.guide(_key(name, v)) @app.get("/api/topics/{name}/guide.md", response_class=PlainTextResponse) -async def guide_md(name: str): - _pruefen(name) - return montage.markdown(name) +async def guide_md(name: str, v: int | None = None): + return montage.markdown(_key(name, v)) @app.get("/api/topics/{name}/buch.pdf") -async def buch_pdf(name: str): - _pruefen(name) - p = state.verzeichnis(name) / "buch" / "buch.pdf" +async def buch_pdf(name: str, v: int | None = None): + key = _key(name, v) + p = state.verzeichnis(key) / "buch" / "buch.pdf" if not p.exists(): raise HTTPException(404, "PDF noch nicht gebaut") - return FileResponse(p, media_type="application/pdf", filename=f"{name}.pdf") + return FileResponse(p, media_type="application/pdf", filename=f"{name} v{key.rsplit('/v', 1)[1]}.pdf") if config.FRONTEND_DIST.exists(): diff --git a/backend/state.py b/backend/state.py index 2ce7b4d..251757d 100644 --- a/backend/state.py +++ b/backend/state.py @@ -1,4 +1,5 @@ -"""Zustand je Thema: storage//{thema.json,state.json,log.jsonl}. Alles im Speicher, bei Änderung auf Platte.""" +"""Zustand je Themenversion. Ablage: storage//thema.json und storage//v/{state.json, deutung.json, quellen/, buch/, …}. +Intern heißt eine Version „/v“ (Schlüssel); alle Funktionen mit `key` erwarten diesen Schlüssel. Alles im Speicher, bei Änderung auf Platte.""" import json import re import shutil @@ -18,8 +19,56 @@ def name_ok(name: str) -> bool: return bool(name) and name.strip() == name and "/" not in name and ".." not in name and len(name) < 120 -def verzeichnis(name: str) -> Path: - return config.STORAGE / name +def verzeichnis(key: str) -> Path: + return config.STORAGE / key + + +def thema_von(key: str) -> str: + """„Godot 4/v2“ → „Godot 4“; ein reiner Themenname bleibt unverändert.""" + return key.rsplit("/", 1)[0] if "/" in key else key + + +def versionen(name: str) -> list[int]: + d = config.STORAGE / name + return sorted(int(x.name[1:]) for x in d.glob("v[0-9]*") if x.is_dir() and x.name[1:].isdigit()) + + +def schluessel(name: str, v: int | None = None) -> str | None: + """Schlüssel einer Version; ohne v die neueste. None, wenn Thema oder Version fehlen.""" + vs = versionen(name) + if not vs or (v is not None and v not in vs): + return None + return f"{name}/v{vs[-1] if v is None else v}" + + +def version_anlegen(name: str) -> str: + n = (versionen(name) or [0])[-1] + 1 + key = f"{name}/v{n}" + v = verzeichnis(key) + for u in ("quellen", "buch", "berichte", "arbeit"): + (v / u).mkdir(parents=True) + _schreiben(v / "state.json", leerer_zustand()) + return key + + +def version_loeschen(key: str) -> None: + _cache.pop(key, None) + shutil.rmtree(verzeichnis(key), ignore_errors=True) + name = thema_von(key) + if not versionen(name): + shutil.rmtree(config.STORAGE / name, ignore_errors=True) + + +def migrieren() -> None: + """Alte Ablage (alles direkt unter storage//) einmalig nach v1/ verschieben.""" + for d in config.STORAGE.glob("*/"): + if not (d / "thema.json").exists() or not (d / "state.json").exists() or (d / "v1").exists(): + continue + v1 = d / "v1" + v1.mkdir() + for kind in list(d.iterdir()): + if kind.name not in ("thema.json", "v1"): + shutil.move(str(kind), str(v1 / kind.name)) def _lesen(pfad: Path, standard): @@ -37,7 +86,7 @@ def _schreiben(pfad: Path, daten) -> None: def leerer_zustand() -> dict: - return {"status": "neu", "grund": "", "stufe": "", "deutung": None, + return {"status": "neu", "grund": "", "stufe": "", "deutung": None, "angelegt": jetzt(), "stufen": [{"id": s, "titel": t, "status": "offen", "fertig": 0, "gesamt": 0, "kosten_usd": 0.0, "tokens": 0, "gestartet": "", "beendet": ""} for s, t in config.STUFEN], "kosten_usd": 0.0, "tokens": 0, "agenten": [], "ereignisse": []} @@ -53,38 +102,36 @@ def themen() -> list[dict]: def anlegen(name: str, hinweise: str) -> dict: - v = verzeichnis(name) - if v.exists(): + d = config.STORAGE / name + if d.exists(): raise ValueError("Thema existiert schon") - for u in ("quellen", "buch", "berichte", "arbeit"): - (v / u).mkdir(parents=True) + d.mkdir(parents=True) t = {"name": name, "titel": name, "hinweise": hinweise.strip(), "angelegt": jetzt()} - _schreiben(v / "thema.json", t) - _schreiben(v / "state.json", leerer_zustand()) + _schreiben(d / "thema.json", t) + version_anlegen(name) return t def loeschen(name: str) -> None: - _cache.pop(name, None) - shutil.rmtree(verzeichnis(name), ignore_errors=True) + for k in [k for k in _cache if thema_von(k) == name]: + _cache.pop(k, None) + shutil.rmtree(config.STORAGE / name, ignore_errors=True) -def zuruecksetzen(name: str) -> None: - v = verzeichnis(name) - t = _lesen(v / "thema.json", None) - _cache.pop(name, None) +def zuruecksetzen(key: str) -> None: + v = verzeichnis(key) + _cache.pop(key, None) for u in ("quellen", "buch", "berichte", "arbeit"): shutil.rmtree(v / u, ignore_errors=True) (v / u).mkdir() - for f in ("deutung.json", "log.jsonl", "state.json"): + for f in ("deutung.json", "log.jsonl", "state.json", "position.json"): (v / f).unlink(missing_ok=True) _schreiben(v / "state.json", leerer_zustand()) - if t: - _schreiben(v / "thema.json", t) -def thema(name: str) -> dict | None: - return _lesen(verzeichnis(name) / "thema.json", None) +def thema(key: str) -> dict | None: + """thema.json des Themas – nimmt Schlüssel oder Themenname.""" + return _lesen(config.STORAGE / thema_von(key) / "thema.json", None) def zustand(name: str) -> dict: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 934e330..b69b0c6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,6 +7,7 @@ import Guide from './components/Guide.vue' const params = new URLSearchParams(location.search) const topics = ref([]) const topic = ref(params.get('topic') || localStorage.getItem('topic') || '') +const version = ref(Number(params.get('v')) || null) // null = neueste Version const tab = ref((params.get('tab') || localStorage.getItem('tab')) === 'buch' ? 'buch' : 'lauf') const theme = ref(localStorage.getItem('theme') || 'papier') const state = ref(null) @@ -30,14 +31,24 @@ themeAnwenden() function urlSchreiben(hashBehalten) { const p = new URLSearchParams() if (topic.value) p.set('topic', topic.value) + if (version.value) p.set('v', version.value) p.set('tab', tab.value) history.replaceState(null, '', location.pathname + '?' + p + (hashBehalten ? location.hash : '')) } watch(topic, (t) => { localStorage.setItem('topic', t); urlSchreiben(false) }) +watch(version, () => urlSchreiben(false)) watch(tab, (t) => { localStorage.setItem('tab', t); urlSchreiben(true) }) const aktuell = computed(() => topics.value.find((t) => t.name === topic.value)) -watch([aktuell, tab], ([a, t]) => { if (t !== 'buch') document.title = a ? `${a.titel} – buchwerk` : 'buchwerk' }, { immediate: true }) +const versionen = computed(() => aktuell.value?.versionen || []) +const neueste = computed(() => versionen.value.length ? versionen.value[versionen.value.length - 1].v : null) +const versionAktiv = computed(() => version.value || neueste.value) +watch([aktuell, tab, versionAktiv], ([a, t, v]) => { + if (t !== 'buch') document.title = a ? `${a.titel}${versionen.value.length > 1 ? ' v' + v : ''} – buchwerk` : 'buchwerk' +}, { immediate: true }) +function datum(iso) { return iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '' } +function themaWaehlen(name) { topic.value = name; version.value = null; tab.value = 'lauf'; navAuf.value = false } +function versionWaehlen(name, v) { topic.value = name; version.value = v; tab.value = 'buch'; navAuf.value = false } const laeuft = computed(() => state.value?.status === 'laeuft') const fortschritt = computed(() => (state.value?.stufen || []).reduce((n, s) => n + (s.fertig || 0), 0)) @@ -46,6 +57,7 @@ async function topicsLaden() { topics.value = await api.topics() fehler.value = '' if (topic.value && !topics.value.some((t) => t.name === topic.value)) topic.value = '' + if (version.value && !versionen.value.some((x) => x.v === version.value)) version.value = null if (!topic.value && topics.value.length) topic.value = topics.value[0].name } catch (e) { // Server gerade nicht erreichbar (z. B. Neustart): alle 3 s erneut versuchen, bis er antwortet @@ -55,7 +67,7 @@ async function topicsLaden() { } async function stateLaden() { if (!topic.value) { state.value = null; return } - try { state.value = await api.state(topic.value) } catch (e) { fehler.value = String(e.message || e) } + try { state.value = await api.state(topic.value, version.value) } catch (e) { fehler.value = String(e.message || e) } } function pollen() { clearInterval(timer) @@ -65,7 +77,7 @@ function pollen() { if (!laeuft.value) { clearInterval(timer); timer = null; await topicsLaden() } }, 2000) } -watch(topic, async () => { await stateLaden(); if (laeuft.value) pollen() }) +watch([topic, version], async () => { await stateLaden(); if (laeuft.value) pollen() }) watch(laeuft, (l) => { if (l && !timer) pollen() }) async function anlegen() { @@ -76,8 +88,17 @@ async function anlegen() { neuName.value = '' neuHinweise.value = '' await topicsLaden() - topic.value = name + themaWaehlen(name) + } catch (e) { fehler.value = String(e.message || e) } +} +// Neue Version: gleiche Eingaben, frischer Lauf; die alte Version bleibt samt Leseposition erhalten. +async function neueVersion() { + try { + const r = await api.versionAnlegen(topic.value) + await topicsLaden() + version.value = r.v tab.value = 'lauf' + await aktion(() => api.start(topic.value, r.v)) } catch (e) { fehler.value = String(e.message || e) } } async function aktion(fn) { @@ -85,7 +106,7 @@ async function aktion(fn) { catch (e) { fehler.value = String(e.message || e) } } async function loeschen(name) { - if (!confirm(`Thema „${name}“ samt Buch löschen?`)) return + if (!confirm(`Thema „${name}“ löschen?`)) return await aktion(() => api.loeschen(name)) } @@ -108,12 +129,21 @@ function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? M