Das Häkchen steht im Lauf neben dem Startknopf und bei „Neue Version“ neben „Buch erzeugen“, nicht mehr im Anlegen-Formular. Es wird mit dem Start gespeichert und ist sichtbar, solange die Gliederung aussteht. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
214 lines
7.0 KiB
Python
214 lines
7.0 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 = ""
|
||
spezialisierung: str = ""
|
||
|
||
|
||
class VersionNeu(BaseModel):
|
||
hinweise: str | None = None # None: wie die bisherige Version
|
||
stopp_gliederung: bool = False
|
||
|
||
|
||
class Start(BaseModel):
|
||
stopp_gliederung: bool | None = None # None: wie gespeichert
|
||
|
||
|
||
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"], "thema": t.get("thema") or t["name"], "spezialisierung": t.get("spezialisierung", ""),
|
||
"titel": t["titel"], "hinweise": t.get("hinweise", ""),
|
||
"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) or not state.name_ok(state.bezeichnung(name, body.spezialisierung.strip())):
|
||
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 wahlweise geändert."""
|
||
b = body or VersionNeu()
|
||
key = state.version_anlegen(_thema(slug)["name"], b.hinweise, b.stopp_gliederung)
|
||
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"], "thema": t.get("thema") or t["name"], "version": n, "titel": t["titel"], **st,
|
||
"hinweise": state.hinweise_von(key), "spezialisierung": state.spezialisierung_von(key),
|
||
"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, body: Start | None = None):
|
||
key = _key(slug, n)
|
||
st = state.zustand(key)
|
||
if st["status"] == "fertig":
|
||
raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen")
|
||
if body and body.stopp_gliederung is not None:
|
||
st["stopp_gliederung"] = body.stopp_gliederung
|
||
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")
|