novela/containers/novela/routers/builder.py
2026-03-26 10:24:57 +01:00

271 lines
9.8 KiB
Python

"""Book Builder — routes voor het handmatig aanmaken van EPUB-boeken."""
import json
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from db import get_db_conn
from epub import build_epub
from routers.common import LIBRARY_DIR, make_rel_path, upsert_book
from xhtml import normalize_wysiwyg_html
router = APIRouter()
templates = Jinja2Templates(directory="templates")
# ── Helpers ───────────────────────────────────────────────────────────────────
def _get_draft(conn, draft_id: str) -> dict | None:
with conn.cursor() as cur:
cur.execute(
"SELECT id, title, author, publisher, source_url, chapters "
"FROM builder_drafts WHERE id = %s",
(draft_id,),
)
row = cur.fetchone()
if not row:
return None
return {
"id": str(row[0]),
"title": row[1],
"author": row[2],
"publisher": row[3],
"source_url": row[4],
"chapters": row[5] or [],
}
# ── Pagina-routes ─────────────────────────────────────────────────────────────
@router.get("/builder", response_class=HTMLResponse)
async def builder_index(request: Request):
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id, title, author, updated_at "
"FROM builder_drafts ORDER BY updated_at DESC"
)
rows = cur.fetchall()
drafts = [
{"id": str(r[0]), "title": r[1], "author": r[2], "updated_at": r[3]}
for r in rows
]
return templates.TemplateResponse(
request, "builder.html", {"view": "index", "drafts": drafts, "active": "builder"}
)
@router.get("/builder/{draft_id}", response_class=HTMLResponse)
async def builder_editor(draft_id: str, request: Request):
with get_db_conn() as conn:
draft = _get_draft(conn, draft_id)
if not draft:
return HTMLResponse("Draft niet gevonden", status_code=404)
return templates.TemplateResponse(
request, "builder.html", {"view": "editor", "draft": draft, "active": "builder"}
)
# ── Draft aanmaken / verwijderen ──────────────────────────────────────────────
@router.post("/builder")
async def create_draft(request: Request):
form = await request.form()
title = (form.get("title") or "").strip()
author = (form.get("author") or "").strip()
publisher = (form.get("publisher") or "").strip()
source_url = (form.get("source_url") or "").strip()
if not title or not author:
return HTMLResponse("Titel en auteur zijn verplicht", status_code=400)
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO builder_drafts (title, author, publisher, source_url, chapters) "
"VALUES (%s, %s, %s, %s, '[]'::jsonb) RETURNING id",
(title, author, publisher, source_url),
)
draft_id = str(cur.fetchone()[0])
conn.commit()
return RedirectResponse(f"/builder/{draft_id}", status_code=303)
@router.delete("/api/builder/{draft_id}")
async def delete_draft(draft_id: str):
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM builder_drafts WHERE id = %s", (draft_id,))
conn.commit()
return JSONResponse({"ok": True})
@router.get("/api/builder/{draft_id}")
async def get_draft_api(draft_id: str):
with get_db_conn() as conn:
draft = _get_draft(conn, draft_id)
if not draft:
return JSONResponse({"error": "not found"}, status_code=404)
return JSONResponse(draft)
# ── Chapter CRUD ──────────────────────────────────────────────────────────────
@router.post("/api/builder/{draft_id}/chapter")
async def add_chapter(draft_id: str, request: Request):
body = await request.json()
title = (body.get("title") or "Nieuw hoofdstuk").strip() or "Nieuw hoofdstuk"
after_index = int(body.get("after_index", -1))
with get_db_conn() as conn:
draft = _get_draft(conn, draft_id)
if not draft:
return JSONResponse({"error": "not found"}, status_code=404)
chapters = draft["chapters"]
new_chapter = {"title": title, "content": "<p></p>"}
insert_at = after_index + 1 if 0 <= after_index < len(chapters) else len(chapters)
chapters.insert(insert_at, new_chapter)
with conn.cursor() as cur:
cur.execute(
"UPDATE builder_drafts SET chapters = %s::jsonb, updated_at = NOW() "
"WHERE id = %s",
(json.dumps(chapters), draft_id),
)
conn.commit()
return JSONResponse({"ok": True, "index": insert_at, "count": len(chapters)})
@router.put("/api/builder/{draft_id}/chapter/{idx}")
async def save_chapter(draft_id: str, idx: int, request: Request):
body = await request.json()
with get_db_conn() as conn:
draft = _get_draft(conn, draft_id)
if not draft:
return JSONResponse({"error": "not found"}, status_code=404)
chapters = draft["chapters"]
if idx < 0 or idx >= len(chapters):
return JSONResponse({"error": "chapter not found"}, status_code=404)
if "title" in body:
chapters[idx]["title"] = body["title"]
if "content" in body:
chapters[idx]["content"] = body["content"]
with conn.cursor() as cur:
cur.execute(
"UPDATE builder_drafts SET chapters = %s::jsonb, updated_at = NOW() "
"WHERE id = %s",
(json.dumps(chapters), draft_id),
)
conn.commit()
return JSONResponse({"ok": True})
@router.delete("/api/builder/{draft_id}/chapter/{idx}")
async def delete_chapter(draft_id: str, idx: int):
with get_db_conn() as conn:
draft = _get_draft(conn, draft_id)
if not draft:
return JSONResponse({"error": "not found"}, status_code=404)
chapters = draft["chapters"]
if idx < 0 or idx >= len(chapters):
return JSONResponse({"error": "chapter not found"}, status_code=404)
if len(chapters) <= 1:
return JSONResponse(
{"error": "Kan het laatste hoofdstuk niet verwijderen"}, status_code=400
)
chapters.pop(idx)
with conn.cursor() as cur:
cur.execute(
"UPDATE builder_drafts SET chapters = %s::jsonb, updated_at = NOW() "
"WHERE id = %s",
(json.dumps(chapters), draft_id),
)
conn.commit()
return JSONResponse({"ok": True, "index": min(idx, len(chapters) - 1), "count": len(chapters)})
# ── Normaliseer preview ───────────────────────────────────────────────────────
@router.post("/api/builder/{draft_id}/normalize/{idx}")
async def normalize_chapter_preview(draft_id: str, idx: int, request: Request):
"""Normaliseer één hoofdstuk en geef genormaliseerde HTML terug als preview.
Slaat het resultaat NIET op — de frontend vraagt bevestiging.
"""
body = await request.json()
raw_html = body.get("content", "")
normalized = normalize_wysiwyg_html(raw_html)
return JSONResponse({"ok": True, "content": normalized})
# ── Publiceer als EPUB ────────────────────────────────────────────────────────
@router.post("/api/builder/{draft_id}/publish")
async def publish_draft(draft_id: str):
with get_db_conn() as conn:
draft = _get_draft(conn, draft_id)
if not draft:
return JSONResponse({"error": "not found"}, status_code=404)
if not draft["chapters"]:
return JSONResponse({"error": "Geen hoofdstukken om te publiceren"}, status_code=400)
# Normaliseer alle hoofdstukken
normalized_chapters = [
{"title": ch["title"], "content": normalize_wysiwyg_html(ch["content"])}
for ch in draft["chapters"]
]
# Bouw EPUB
epub_bytes = build_epub(
title=draft["title"],
author=draft["author"],
publisher=draft["publisher"],
chapters=normalized_chapters,
)
# Bepaal bestandspad
rel_path = make_rel_path(
media_type="epub",
publisher=draft["publisher"] or "Unknown",
author=draft["author"],
title=draft["title"],
series="",
series_index=0,
ext=".epub",
)
dest = LIBRARY_DIR / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(epub_bytes)
filename = str(rel_path)
meta = {
"media_type": "epub",
"title": draft["title"],
"author": draft["author"],
"publisher": draft["publisher"],
"source_url": draft["source_url"],
"needs_review": True,
"has_cover": False,
}
upsert_book(conn, filename, meta)
with conn.cursor() as cur:
cur.execute("DELETE FROM builder_drafts WHERE id = %s", (draft_id,))
conn.commit()
return JSONResponse({"ok": True, "filename": filename})