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>
170 lines
5.4 KiB
Python
170 lines
5.4 KiB
Python
import json
|
||
import logging
|
||
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.responses import FileResponse, PlainTextResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.middleware.gzip import GZipMiddleware
|
||
from pydantic import BaseModel
|
||
|
||
from . import config, montage, pipeline, state
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||
|
||
|
||
@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)
|
||
|
||
|
||
app = FastAPI(title="buchwerk", lifespan=lifespan)
|
||
app.add_middleware(GZipMiddleware, minimum_size=1000) # Buchdaten sind 1–2 MB JSON
|
||
|
||
|
||
class ThemaNeu(BaseModel):
|
||
name: str
|
||
hinweise: str = ""
|
||
|
||
|
||
class Position(BaseModel):
|
||
abschnitt: str | None = None
|
||
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):
|
||
raise HTTPException(404, "Thema unbekannt")
|
||
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():
|
||
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
|
||
|
||
|
||
@app.post("/api/topics")
|
||
async def topic_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)
|
||
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.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)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/topics/{name}/start")
|
||
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(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))
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/topics/{name}/reset")
|
||
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, 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, v: int | None = None):
|
||
return state.position(_key(name, v))
|
||
|
||
|
||
@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/topics/{name}/guide")
|
||
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, 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)
|
||
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")
|
||
|
||
|
||
if config.FRONTEND_DIST.exists():
|
||
app.mount("/assets", StaticFiles(directory=config.FRONTEND_DIST / "assets"), name="assets")
|
||
|
||
@app.get("/{pfad:path}")
|
||
async def spa(pfad: str):
|
||
return FileResponse(config.FRONTEND_DIST / "index.html")
|