271 lines
9.8 KiB
Python
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})
|