Adressen: Slug und Version im Pfad statt Parameter
Seite: /<slug>[/v<N>][/lauf|/buch|/lesen][/<Abschnitt>], Zurück-Taste je Abschnitt. API: /api/themen/<slug>/v<N>/… mit Version als Pflichtteil des Pfads; /api/topics entfällt. Slugs stehen in thema.json und werden beim Start nachgetragen. Unbekannte API-Pfade liefern 404 statt der Seite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
156
backend/main.py
156
backend/main.py
@@ -36,11 +36,18 @@ class Position(BaseModel):
|
||||
lesemodus: str | int | None = None # neu: key des Bildschirms, alt: Index
|
||||
|
||||
|
||||
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):
|
||||
def _thema(slug: str) -> dict:
|
||||
name = state.name_von_slug(slug)
|
||||
t = state.thema(name) if name else None
|
||||
if not t:
|
||||
raise HTTPException(404, "Thema unbekannt")
|
||||
key = state.schluessel(name, v)
|
||||
return t
|
||||
|
||||
|
||||
def _key(slug: str, n: int) -> str:
|
||||
"""Slug + Versionsnummer → interner Schlüssel „Thema/vN“."""
|
||||
t = _thema(slug)
|
||||
key = state.schluessel(t["name"], n)
|
||||
if not key:
|
||||
raise HTTPException(404, "Version unbekannt")
|
||||
return key
|
||||
@@ -54,111 +61,120 @@ def _version_info(name: str, n: int) -> dict:
|
||||
"angelegt": angelegt, "kosten_usd": st.get("kosten_usd", 0)}
|
||||
|
||||
|
||||
@app.get("/api/topics")
|
||||
async def topics():
|
||||
aus = []
|
||||
for t in state.themen():
|
||||
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
|
||||
def _thema_info(t: dict) -> dict | None:
|
||||
vs = [_version_info(t["name"], n) for n in state.versionen(t["name"])]
|
||||
if not vs:
|
||||
return None
|
||||
neueste = vs[-1]
|
||||
return {"slug": t["slug"], "name": t["name"], "titel": t["titel"], "hinweise": t.get("hinweise", ""),
|
||||
"angelegt": t.get("angelegt", ""), "status": neueste["status"], "laeuft": neueste["laeuft"],
|
||||
"stufe": neueste["stufe"], "versionen": vs}
|
||||
|
||||
|
||||
@app.post("/api/topics")
|
||||
async def topic_anlegen(body: ThemaNeu):
|
||||
@app.get("/api/themen")
|
||||
async def themen():
|
||||
return [i for t in state.themen() if (i := _thema_info(t))]
|
||||
|
||||
|
||||
@app.post("/api/themen")
|
||||
async def thema_anlegen(body: ThemaNeu):
|
||||
name = body.name.strip()
|
||||
if not state.name_ok(name):
|
||||
raise HTTPException(400, "Ungültiger Name")
|
||||
try:
|
||||
return state.anlegen(name, body.hinweise)
|
||||
return _thema_info(state.anlegen(name, body.hinweise))
|
||||
except ValueError as e:
|
||||
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.get("/api/themen/{slug}")
|
||||
async def thema(slug: str):
|
||||
return _thema_info(_thema(slug))
|
||||
|
||||
|
||||
@app.delete("/api/topics/{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)
|
||||
@app.delete("/api/themen/{slug}")
|
||||
async def thema_loeschen(slug: str):
|
||||
t = _thema(slug)
|
||||
for n in state.versionen(t["name"]):
|
||||
await pipeline.pausieren(f"{t['name']}/v{n}")
|
||||
state.loeschen(t["name"])
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/topics/{name}/start")
|
||||
async def start(name: str, v: int | None = None):
|
||||
key = _key(name, v)
|
||||
@app.post("/api/themen/{slug}/versionen")
|
||||
async def version_anlegen(slug: str):
|
||||
"""Neue Version: gleiche Eingaben, frischer Lauf, ohne Wissen über die Vorgänger."""
|
||||
key = state.version_anlegen(_thema(slug)["name"])
|
||||
return {"v": int(key.rsplit("/v", 1)[1])}
|
||||
|
||||
|
||||
@app.get("/api/themen/{slug}/v{n}")
|
||||
async def zustand(slug: str, n: int):
|
||||
key = _key(slug, n)
|
||||
t = _thema(slug)
|
||||
st = state.zustand(key)
|
||||
return {"slug": slug, "name": t["name"], "version": n, "titel": t["titel"], "hinweise": t.get("hinweise", ""), **st,
|
||||
"deutung": st.get("deutung") or state.deutung(key), "ereignisse": st["ereignisse"][-100:]}
|
||||
|
||||
|
||||
@app.delete("/api/themen/{slug}/v{n}")
|
||||
async def version_loeschen(slug: str, n: int):
|
||||
key = _key(slug, n)
|
||||
await pipeline.pausieren(key)
|
||||
state.version_loeschen(key)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/themen/{slug}/v{n}/start")
|
||||
async def start(slug: str, n: int):
|
||||
key = _key(slug, n)
|
||||
if state.zustand(key)["status"] == "fertig":
|
||||
raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen")
|
||||
pipeline.starten(key)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/topics/{name}/pause")
|
||||
async def pause(name: str, v: int | None = None):
|
||||
await pipeline.pausieren(_key(name, v))
|
||||
@app.post("/api/themen/{slug}/v{n}/pause")
|
||||
async def pause(slug: str, n: int):
|
||||
await pipeline.pausieren(_key(slug, n))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/topics/{name}/reset")
|
||||
async def reset(name: str, v: int | None = None):
|
||||
key = _key(name, v)
|
||||
@app.post("/api/themen/{slug}/v{n}/reset")
|
||||
async def reset(slug: str, n: int):
|
||||
key = _key(slug, n)
|
||||
await pipeline.pausieren(key)
|
||||
state.zuruecksetzen(key)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/topics/{name}/state")
|
||||
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/themen/{slug}/v{n}/position")
|
||||
async def position_lesen(slug: str, n: int):
|
||||
return state.position(_key(slug, n))
|
||||
|
||||
|
||||
@app.get("/api/topics/{name}/position")
|
||||
async def position_lesen(name: str, v: int | None = None):
|
||||
return state.position(_key(name, v))
|
||||
@app.put("/api/themen/{slug}/v{n}/position")
|
||||
async def position_schreiben(slug: str, n: int, body: Position):
|
||||
return state.position_setzen(_key(slug, n), {k: val for k, val in body.model_dump().items() if val is not None})
|
||||
|
||||
|
||||
@app.put("/api/topics/{name}/position")
|
||||
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/themen/{slug}/v{n}/buch")
|
||||
async def buch(slug: str, n: int):
|
||||
return montage.guide(_key(slug, n))
|
||||
|
||||
|
||||
@app.get("/api/topics/{name}/guide")
|
||||
async def guide(name: str, v: int | None = None):
|
||||
return montage.guide(_key(name, v))
|
||||
@app.get("/api/themen/{slug}/v{n}/buch.md", response_class=PlainTextResponse)
|
||||
async def buch_md(slug: str, n: int):
|
||||
return montage.markdown(_key(slug, n))
|
||||
|
||||
|
||||
@app.get("/api/topics/{name}/guide.md", response_class=PlainTextResponse)
|
||||
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, v: int | None = None):
|
||||
key = _key(name, v)
|
||||
@app.get("/api/themen/{slug}/v{n}/buch.pdf")
|
||||
async def buch_pdf(slug: str, n: int):
|
||||
key = _key(slug, n)
|
||||
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} v{key.rsplit('/v', 1)[1]}.pdf")
|
||||
return FileResponse(p, media_type="application/pdf", filename=f"{slug}-v{n}.pdf")
|
||||
|
||||
|
||||
if config.FRONTEND_DIST.exists():
|
||||
@@ -166,4 +182,6 @@ if config.FRONTEND_DIST.exists():
|
||||
|
||||
@app.get("/{pfad:path}")
|
||||
async def spa(pfad: str):
|
||||
if pfad.startswith("api/"):
|
||||
raise HTTPException(404, "Unbekannter API-Pfad")
|
||||
return FileResponse(config.FRONTEND_DIST / "index.html")
|
||||
|
||||
@@ -41,6 +41,20 @@ def schluessel(name: str, v: int | None = None) -> str | None:
|
||||
return f"{name}/v{vs[-1] if v is None else v}"
|
||||
|
||||
|
||||
def slug_frei(name: str) -> str:
|
||||
"""Eindeutiger Slug für die Adresse: „Godot 4“ → „godot-4“; bei Kollision „godot-4-2“."""
|
||||
basis = slug(name)
|
||||
vergeben = {t.get("slug") for t in themen()}
|
||||
k, i = basis, 2
|
||||
while k in vergeben:
|
||||
k, i = f"{basis}-{i}", i + 1
|
||||
return k
|
||||
|
||||
|
||||
def name_von_slug(sl: str) -> str | None:
|
||||
return next((t["name"] for t in themen() if t.get("slug") == sl), None)
|
||||
|
||||
|
||||
def version_anlegen(name: str) -> str:
|
||||
n = (versionen(name) or [0])[-1] + 1
|
||||
key = f"{name}/v{n}"
|
||||
@@ -60,8 +74,12 @@ def version_loeschen(key: str) -> None:
|
||||
|
||||
|
||||
def migrieren() -> None:
|
||||
"""Alte Ablage (alles direkt unter storage/<Thema>/) einmalig nach v1/ verschieben."""
|
||||
"""Alte Ablage (alles direkt unter storage/<Thema>/) einmalig nach v1/ verschieben; Slugs nachtragen."""
|
||||
for d in config.STORAGE.glob("*/"):
|
||||
t = _lesen(d / "thema.json", None)
|
||||
if t and not t.get("slug"):
|
||||
t["slug"] = slug_frei(t["name"])
|
||||
_schreiben(d / "thema.json", t)
|
||||
if not (d / "thema.json").exists() or not (d / "state.json").exists() or (d / "v1").exists():
|
||||
continue
|
||||
v1 = d / "v1"
|
||||
@@ -106,7 +124,7 @@ def anlegen(name: str, hinweise: str) -> dict:
|
||||
if d.exists():
|
||||
raise ValueError("Thema existiert schon")
|
||||
d.mkdir(parents=True)
|
||||
t = {"name": name, "titel": name, "hinweise": hinweise.strip(), "angelegt": jetzt()}
|
||||
t = {"name": name, "slug": slug_frei(name), "titel": name, "hinweise": hinweise.strip(), "angelegt": jetzt()}
|
||||
_schreiben(d / "thema.json", t)
|
||||
version_anlegen(name)
|
||||
return t
|
||||
@@ -213,7 +231,7 @@ def slug(text: str) -> str:
|
||||
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
|
||||
t = t.replace(a, b)
|
||||
t = re.sub(r"[^a-z0-9]+", "-", t).strip("-")
|
||||
return t[:60] or "kapitel"
|
||||
return t[:60] or "thema"
|
||||
|
||||
|
||||
def position(name: str) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user