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 = "" spezialisierung: str = "" class VersionNeu(BaseModel): hinweise: str | None = None # None: wie die bisherige Version spezialisierung: str | None = None class Position(BaseModel): abschnitt: str | None = None lesemodus: str | int | None = None # neu: key des Bildschirms, alt: Index zuletzt: str | None = None # 'buch' oder 'lesen': welche Ansicht zuletzt gelesen wurde 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") 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 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)} 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", ""), "spezialisierung": t.get("spezialisierung", ""), "angelegt": t.get("angelegt", ""), "status": neueste["status"], "laeuft": neueste["laeuft"], "stufe": neueste["stufe"], "versionen": vs} @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 _thema_info(state.anlegen(name, body.hinweise, body.spezialisierung)) except ValueError as e: raise HTTPException(409, str(e)) @app.get("/api/themen/{slug}") async def thema(slug: str): return _thema_info(_thema(slug)) @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/themen/{slug}/versionen") async def version_anlegen(slug: str, body: VersionNeu | None = None): """Neue Version: frischer Lauf ohne Wissen über die Vorgänger; Hinweise und Spezialisierung wahlweise geändert.""" b = body or VersionNeu() key = state.version_anlegen(_thema(slug)["name"], b.hinweise, b.spezialisierung) 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) h, sp = state.eingaben(key) return {"slug": slug, "name": t["name"], "version": n, "titel": t["titel"], **st, "hinweise": h, "spezialisierung": sp, "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/themen/{slug}/v{n}/pause") async def pause(slug: str, n: int): await pipeline.pausieren(_key(slug, n)) return {"ok": True} @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/themen/{slug}/v{n}/position") async def position_lesen(slug: str, n: int): return state.position(_key(slug, n)) @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.get("/api/themen/{slug}/v{n}/buch") async def buch(slug: str, n: int): return montage.guide(_key(slug, n)) @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/themen/{slug}/v{n}/kern.md", response_class=PlainTextResponse) async def kern_md(slug: str, n: int): return montage.kern(_key(slug, n)) @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"{slug}-v{n}.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): if pfad.startswith("api/"): raise HTTPException(404, "Unbekannter API-Pfad") # Statische Dateien des Frontends (Manifest, Icons) direkt ausliefern, alles andere ist die App datei = (config.FRONTEND_DIST / pfad).resolve() if pfad and datei.is_file() and config.FRONTEND_DIST.resolve() in datei.parents: return FileResponse(datei) return FileResponse(config.FRONTEND_DIST / "index.html")