Versionen je Thema
Ein Thema hat Versionen storage/<Thema>/v<N>/ 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=<Nr>. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
10
API.md
10
API.md
@@ -79,3 +79,13 @@ storage/<name>/
|
||||
berichte/ <agent-id>.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>/thema.json` und `storage/<Thema>/v<N>/…`. 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.
|
||||
|
||||
13
Makefile
13
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=<Name> 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=<Name> angeben (optional V=<Nr>)"; 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),)"
|
||||
|
||||
110
backend/main.py
110
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():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Zustand je Thema: storage/<name>/{thema.json,state.json,log.jsonl}. Alles im Speicher, bei Änderung auf Platte."""
|
||||
"""Zustand je Themenversion. Ablage: storage/<Thema>/thema.json und storage/<Thema>/v<N>/{state.json, deutung.json, quellen/, buch/, …}.
|
||||
Intern heißt eine Version „<Thema>/v<N>“ (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/<Thema>/) 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:
|
||||
|
||||
@@ -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
|
||||
<aside class="sidebar" :class="{ offen: navAuf }">
|
||||
<button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button>
|
||||
<div class="topics">
|
||||
<div v-for="t in topics" :key="t.name" class="topic-eintrag" :class="{ aktiv: t.name === topic }"
|
||||
@click="topic = t.name; navAuf = false">
|
||||
<span class="punkt" :class="t.laeuft ? 'laeuft' : t.status"></span>
|
||||
<span style="flex: 1">{{ t.titel }}</span>
|
||||
<button v-if="t.status !== 'fertig'" class="klein sekundaer" title="Löschen" @click.stop="loeschen(t.name)">✕</button>
|
||||
</div>
|
||||
<template v-for="t in topics" :key="t.name">
|
||||
<div class="topic-eintrag" :class="{ aktiv: t.name === topic && (t.versionen.length === 1 || tab === 'lauf') }"
|
||||
@click="themaWaehlen(t.name)">
|
||||
<span class="punkt" :class="t.laeuft ? 'laeuft' : t.status"></span>
|
||||
<span style="flex: 1">{{ t.titel }}</span>
|
||||
<button v-if="t.versionen.length === 1 && t.status !== 'fertig'" class="klein sekundaer" title="Löschen" @click.stop="loeschen(t.name)">✕</button>
|
||||
</div>
|
||||
<div v-if="t.versionen.length > 1" class="versionen">
|
||||
<div v-for="x in t.versionen" :key="x.v" class="topic-eintrag version"
|
||||
:class="{ aktiv: t.name === topic && tab === 'buch' && x.v === versionAktiv }" @click="versionWaehlen(t.name, x.v)">
|
||||
<span class="punkt" :class="x.laeuft ? 'laeuft' : x.status"></span>
|
||||
<span style="flex: 1">v{{ x.v }} <span class="dim">· {{ datum(x.angelegt) }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!topics.length" style="color: var(--dim); padding: 8px">Noch kein Thema.</div>
|
||||
</div>
|
||||
<div class="neu">
|
||||
@@ -144,10 +174,11 @@ function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? M
|
||||
<div v-if="fehler" class="fehler" @click="fehler = ''">{{ fehler }} — klicken zum Schließen</div>
|
||||
<main class="inhalt" id="inhalt" tabindex="-1">
|
||||
<Lauf v-if="tab === 'lauf' && state" :state="state"
|
||||
@start="aktion(() => api.start(topic))"
|
||||
@pause="aktion(() => api.pause(topic))"
|
||||
@reset="aktion(() => api.reset(topic))" />
|
||||
<Guide v-else-if="tab === 'buch' && topic" :topic="topic" :laeuft="laeuft" :fortschritt="fortschritt" />
|
||||
@start="aktion(() => api.start(topic, version))"
|
||||
@pause="aktion(() => api.pause(topic, version))"
|
||||
@reset="aktion(() => api.reset(topic, version))"
|
||||
@neue-version="neueVersion" />
|
||||
<Guide v-else-if="tab === 'buch' && topic" :topic="topic" :version="version" :laeuft="laeuft" :fortschritt="fortschritt" />
|
||||
<div v-else style="padding: 24px; color: var(--dim)">Thema links anlegen oder wählen.</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -14,17 +14,19 @@ async function req(pfad, opts = {}) {
|
||||
}
|
||||
|
||||
const e = encodeURIComponent
|
||||
const q = (v) => (v ? `?v=${v}` : '') // Version; ohne Angabe die neueste
|
||||
export const api = {
|
||||
topics: () => req('/topics'),
|
||||
anlegen: (name, hinweise = '') => req('/topics', { method: 'POST', body: { name, hinweise } }),
|
||||
loeschen: (t) => req(`/topics/${e(t)}`, { method: 'DELETE' }),
|
||||
start: (t) => req(`/topics/${e(t)}/start`, { method: 'POST' }),
|
||||
pause: (t) => req(`/topics/${e(t)}/pause`, { method: 'POST' }),
|
||||
reset: (t) => req(`/topics/${e(t)}/reset`, { method: 'POST' }),
|
||||
state: (t) => req(`/topics/${e(t)}/state`),
|
||||
guide: (t) => req(`/topics/${e(t)}/guide`),
|
||||
position: (t) => req(`/topics/${e(t)}/position`),
|
||||
positionSetzen: (t, daten) => req(`/topics/${e(t)}/position`, { method: 'PUT', body: daten }),
|
||||
guideMdUrl: (t) => `/api/topics/${e(t)}/guide.md`,
|
||||
pdfUrl: (t) => `/api/topics/${e(t)}/buch.pdf`,
|
||||
versionAnlegen: (t) => req(`/topics/${e(t)}/version`, { method: 'POST' }),
|
||||
loeschen: (t, v) => req(`/topics/${e(t)}${q(v)}`, { method: 'DELETE' }),
|
||||
start: (t, v) => req(`/topics/${e(t)}/start${q(v)}`, { method: 'POST' }),
|
||||
pause: (t, v) => req(`/topics/${e(t)}/pause${q(v)}`, { method: 'POST' }),
|
||||
reset: (t, v) => req(`/topics/${e(t)}/reset${q(v)}`, { method: 'POST' }),
|
||||
state: (t, v) => req(`/topics/${e(t)}/state${q(v)}`),
|
||||
guide: (t, v) => req(`/topics/${e(t)}/guide${q(v)}`),
|
||||
position: (t, v) => req(`/topics/${e(t)}/position${q(v)}`),
|
||||
positionSetzen: (t, daten, v) => req(`/topics/${e(t)}/position${q(v)}`, { method: 'PUT', body: daten }),
|
||||
guideMdUrl: (t, v) => `/api/topics/${e(t)}/guide.md${q(v)}`,
|
||||
pdfUrl: (t, v) => `/api/topics/${e(t)}/buch.pdf${q(v)}`,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { api } from '../api.js'
|
||||
import { render, zeit } from '../markdown.js'
|
||||
import Lesemodus from './Lesemodus.vue'
|
||||
|
||||
const props = defineProps({ topic: String, laeuft: Boolean, fortschritt: Number })
|
||||
const props = defineProps({ topic: String, version: Number, laeuft: Boolean, fortschritt: Number })
|
||||
const daten = ref(null)
|
||||
const lesemodus = ref(false)
|
||||
// Vollbild nur aus der Klickgeste heraus (Browser-Vorgabe); iOS-Safari kennt die API nicht.
|
||||
@@ -39,7 +39,7 @@ let ladeSeq = 0
|
||||
let merkTimer = null
|
||||
function merken(id) {
|
||||
clearTimeout(merkTimer)
|
||||
merkTimer = setTimeout(() => api.positionSetzen(props.topic, { abschnitt: id }).catch(() => {}), 1500)
|
||||
merkTimer = setTimeout(() => api.positionSetzen(props.topic, { abschnitt: id }, props.version).catch(() => {}), 1500)
|
||||
}
|
||||
const cache = new Map() // Schlüssel: Abschnitt + Textlänge → unveränderte Abschnitte werden nicht neu gerendert
|
||||
|
||||
@@ -48,7 +48,7 @@ onUnmounted(() => { beobachter?.disconnect() })
|
||||
async function laden() {
|
||||
if (!props.topic) { daten.value = null; return }
|
||||
const seq = ++ladeSeq
|
||||
const [d, pos] = await Promise.all([api.guide(props.topic), api.position(props.topic).catch(() => ({}))])
|
||||
const [d, pos] = await Promise.all([api.guide(props.topic, props.version), api.position(props.topic, props.version).catch(() => ({}))])
|
||||
if (seq !== ladeSeq) return
|
||||
daten.value = d
|
||||
await nextTick()
|
||||
@@ -57,7 +57,7 @@ async function laden() {
|
||||
const ziel = location.hash.slice(1) || pos.abschnitt
|
||||
if (ziel && !aktiv.value) document.getElementById(ziel)?.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
watch(() => props.topic, () => { aktiv.value = ''; cache.clear(); laden() }, { immediate: true })
|
||||
watch(() => [props.topic, props.version], () => { aktiv.value = ''; cache.clear(); laden() }, { immediate: true })
|
||||
watch(() => props.laeuft, (l, alt) => { if (alt && !l) laden() })
|
||||
// Während eines Laufs nur nachladen, wenn sich der Fortschritt geändert hat
|
||||
watch(() => props.fortschritt, (n, alt) => { if (props.laeuft && n !== alt) laden() })
|
||||
@@ -116,8 +116,8 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
|
||||
<button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button>
|
||||
<div class="werkzeuge">
|
||||
<button class="klein" title="Lesemodus: Abschnitt für Abschnitt" @click="lesemodusStart" :disabled="!abschnitte.length">Lesen ▶</button>
|
||||
<a class="knopf klein sekundaer" title="Als Markdown-Datei herunterladen" :href="api.guideMdUrl(topic)" download>MD</a>
|
||||
<a v-if="daten?.pdf" class="knopf klein sekundaer" title="Als PDF herunterladen" :href="api.pdfUrl(topic)" download>PDF</a>
|
||||
<a class="knopf klein sekundaer" title="Als Markdown-Datei herunterladen" :href="api.guideMdUrl(topic, version)" download>MD</a>
|
||||
<a v-if="daten?.pdf" class="knopf klein sekundaer" title="Als PDF herunterladen" :href="api.pdfUrl(topic, version)" download>PDF</a>
|
||||
</div>
|
||||
<template v-if="daten">
|
||||
<div class="meta">
|
||||
@@ -142,8 +142,8 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
|
||||
<div class="mobil-leiste">
|
||||
<button class="sekundaer klein" @click="navAuf = !navAuf">☰ Inhalt</button>
|
||||
<button class="klein" @click="lesemodusStart" :disabled="!abschnitte.length">Lesen ▶</button>
|
||||
<a class="knopf klein sekundaer" :href="api.guideMdUrl(topic)" download>MD</a>
|
||||
<a v-if="daten?.pdf" class="knopf klein sekundaer" :href="api.pdfUrl(topic)" download>PDF</a>
|
||||
<a class="knopf klein sekundaer" :href="api.guideMdUrl(topic, version)" download>MD</a>
|
||||
<a v-if="daten?.pdf" class="knopf klein sekundaer" :href="api.pdfUrl(topic, version)" download>PDF</a>
|
||||
</div>
|
||||
<article v-if="daten" class="buch">
|
||||
<h1>{{ daten.titel }}</h1>
|
||||
@@ -190,6 +190,6 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<Lesemodus v-if="lesemodus && daten" :daten="daten" :topic="topic" @schliessen="lesemodusEnde" />
|
||||
<Lesemodus v-if="lesemodus && daten" :daten="daten" :topic="topic" :version="version" @schliessen="lesemodusEnde" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
||||
import { render } from '../markdown.js'
|
||||
|
||||
const props = defineProps({ state: Object })
|
||||
const emit = defineEmits(['start', 'pause', 'reset'])
|
||||
const emit = defineEmits(['start', 'pause', 'reset', 'neue-version'])
|
||||
|
||||
const laeuft = computed(() => props.state?.status === 'laeuft')
|
||||
const fertig = computed(() => props.state?.status === 'fertig')
|
||||
@@ -36,6 +36,7 @@ const gruppen = computed(() => {
|
||||
const ereignisse = computed(() => (props.state?.ereignisse || []).slice(-100).reverse())
|
||||
|
||||
function reset() { if (confirm('Lauf zurücksetzen? Alles außer dem Thema wird gelöscht.')) emit('reset') }
|
||||
function neueVersion() { if (confirm('Neue Version erzeugen? Gleiche Eingaben, kompletter neuer Lauf. Die bisherige Version bleibt erhalten.')) emit('neue-version') }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,6 +45,7 @@ function reset() { if (confirm('Lauf zurücksetzen? Alles außer dem Thema wird
|
||||
<button v-if="!laeuft && !fertig" @click="emit('start')">{{ begonnen ? 'Fortsetzen' : 'Buch erzeugen' }}</button>
|
||||
<button v-if="laeuft" class="sekundaer" @click="emit('pause')">Pause</button>
|
||||
<button v-if="!laeuft && begonnen && !fertig" class="sekundaer" @click="reset">Zurücksetzen</button>
|
||||
<button v-if="!laeuft && begonnen" class="sekundaer" title="Gleiches Thema, gleiche Hinweise, neuer Lauf" @click="neueVersion">Neue Version</button>
|
||||
<span class="spacer"></span>
|
||||
<span class="badge" :class="statusFarbe[state.status]">{{ statusText[state.status] || state.status }}<template v-if="state.stufe && laeuft"> · {{ state.stufe }}</template></span>
|
||||
<span class="badge">{{ usd(state.kosten_usd) }}</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { render, zeit } from '../markdown.js'
|
||||
import { api } from '../api.js'
|
||||
|
||||
const props = defineProps({ daten: Object, topic: String })
|
||||
const props = defineProps({ daten: Object, topic: String, version: Number })
|
||||
const emit = defineEmits(['schliessen'])
|
||||
const buehne = ref(null)
|
||||
const pos = ref(0)
|
||||
@@ -53,7 +53,7 @@ watch(pos, (p) => {
|
||||
buehne.value?.scrollTo({ top: 0 })
|
||||
clearTimeout(merkTimer)
|
||||
const key = screens.value[p]?.key
|
||||
if (key) merkTimer = setTimeout(() => api.positionSetzen(props.topic, { lesemodus: key }).catch(() => {}), 1500)
|
||||
if (key) merkTimer = setTimeout(() => api.positionSetzen(props.topic, { lesemodus: key }, props.version).catch(() => {}), 1500)
|
||||
})
|
||||
|
||||
function taste(e) {
|
||||
@@ -82,7 +82,7 @@ function wischEnde(e) {
|
||||
}
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', taste)
|
||||
const merk = (await api.position(props.topic).catch(() => ({}))).lesemodus
|
||||
const merk = (await api.position(props.topic, props.version).catch(() => ({}))).lesemodus
|
||||
// Alte Positionen waren ein Index, neue sind ein key.
|
||||
const i = typeof merk === 'string' ? screens.value.findIndex((s) => s.key === merk) : Number(merk || 0)
|
||||
pos.value = Math.min(Math.max(i, 0), screens.value.length - 1)
|
||||
|
||||
@@ -38,6 +38,9 @@ a.knopf { display: inline-block; text-decoration: none; line-height: normal; }
|
||||
.topic-eintrag { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 6px; cursor: pointer; color: var(--dim); }
|
||||
.topic-eintrag.aktiv { background: var(--karte); color: var(--text); }
|
||||
.topic-eintrag .punkt { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
|
||||
.versionen { margin: -2px 0 4px 18px; border-left: 1px solid var(--rand); }
|
||||
.topic-eintrag.version { padding: 5px 10px; font-size: 13px; }
|
||||
.topic-eintrag .dim { color: var(--dim); font-size: 12px; }
|
||||
.topic-eintrag .punkt.fertig { background: var(--gruen); }
|
||||
.topic-eintrag .punkt.laeuft { background: var(--akzent); animation: puls 1.2s infinite; }
|
||||
.topic-eintrag .punkt.pausiert { background: var(--gelb); }
|
||||
|
||||
Reference in New Issue
Block a user