From 2d672ff7bcf21788e3edd26acb9ebce94296deb5 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Thu, 26 Mar 2026 10:24:57 +0100 Subject: [PATCH] Add Book Builder: WYSIWYG EPUB editor with draft management and publish flow Co-Authored-By: Claude Sonnet 4.6 --- containers/novela/epub.py | 146 +++++++++++ containers/novela/main.py | 2 + containers/novela/migrations.py | 18 ++ containers/novela/routers/__init__.py | 2 + containers/novela/routers/builder.py | 270 ++++++++++++++++++++ containers/novela/static/builder.css | 296 +++++++++++++++++++++ containers/novela/static/builder.js | 298 ++++++++++++++++++++++ containers/novela/static/epub-style.css | 10 + containers/novela/templates/_sidebar.html | 8 + containers/novela/templates/builder.html | 129 ++++++++++ containers/novela/xhtml.py | 84 ++++++ docs/TECHNICAL.md | 15 ++ docs/changelog-develop.md | 13 + 13 files changed, 1291 insertions(+) create mode 100644 containers/novela/routers/builder.py create mode 100644 containers/novela/static/builder.css create mode 100644 containers/novela/static/builder.js create mode 100644 containers/novela/templates/builder.html diff --git a/containers/novela/epub.py b/containers/novela/epub.py index 59c9a96..fef8474 100644 --- a/containers/novela/epub.py +++ b/containers/novela/epub.py @@ -1,7 +1,9 @@ import io import re import zipfile +from datetime import datetime, timezone from html import escape as he +from pathlib import Path def detect_image_format(data: bytes, base: str) -> tuple[str, str]: @@ -430,3 +432,147 @@ def write_epub_file(epub_path, internal_path: str, content: str) -> None: with open(epub_path, "wb") as f: f.write(buf.getvalue()) + + +def build_epub( + title: str, + author: str, + publisher: str, + chapters: list[dict], +) -> bytes: + """Bouw een EPUB 2.0 bestand vanuit builder-data. Geeft raw bytes terug.""" + import uuid as _uuid + book_id = str(_uuid.uuid4()) + now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + + # mimetype — ongecomprimeerd als eerste entry + mi = zipfile.ZipInfo("mimetype") + mi.compress_type = zipfile.ZIP_STORED + z.writestr(mi, "application/epub+zip") + + z.writestr( + "META-INF/container.xml", + '\n' + '\n' + ' \n' + ' \n' + ' \n' + '\n', + ) + + style_css = ( + "body { font-family: Georgia, serif; font-size: 1em;" + " line-height: 1.6; margin: 1em; }\n" + "p { margin: 0 0 0.8em 0; text-indent: 1.2em; }\n" + "p:first-child, h1 + p, h2 + p, h3 + p { text-indent: 0; }\n" + "h1, h2, h3 { font-weight: bold; margin: 1.2em 0 0.4em; }\n" + "blockquote { margin: 1em 2em; padding: 0.3em 0.8em;" + " border-left: 3px solid #aaa; }\n" + "blockquote.author-note { font-style: italic; color: #666;" + " border-left: 3px solid #555; margin: 1.2em 2em;" + " padding: 0.4em 1em; font-size: 0.92em; }\n" + "center img { display: block; margin: 1em auto; }\n" + ) + z.writestr("OEBPS/Styles/style.css", style_css) + + break_png_path = Path("static/break.png") + if break_png_path.exists(): + z.write(str(break_png_path), "OEBPS/Images/break.png") + + manifest_items: list[str] = [] + spine_idrefs: list[str] = [] + ncx_nav_points: list[str] = [] + + for i, ch in enumerate(chapters): + ch_id = f"chapter_{i + 1:03d}" + ch_filename = f"OEBPS/Text/{ch_id}.xhtml" + ch_title = he(ch.get("title") or f"Hoofdstuk {i + 1}") + ch_content = ch.get("content") or "

" + + xhtml = ( + '\n' + '\n' + '\n' + "\n" + ' \n' + f" {ch_title}\n" + ' \n' + "\n" + "\n" + f"

{ch_title}

\n" + f" {ch_content}\n" + "\n" + "\n" + ) + z.writestr(ch_filename, xhtml) + + manifest_items.append( + f' ' + ) + spine_idrefs.append(f' ') + ncx_nav_points.append( + f' \n' + f' {ch_title}\n' + f' \n' + f' ' + ) + + safe_title = he(title) + safe_author = he(author) + + ncx = ( + '\n' + '\n' + '\n' + "\n" + f' \n' + ' \n' + ' \n' + ' \n' + "\n" + f"{safe_title}\n" + f"{safe_author}\n" + "\n" + + "\n".join(ncx_nav_points) + + "\n\n\n" + ) + z.writestr("OEBPS/toc.ncx", ncx) + + has_break = break_png_path.exists() + opf = ( + '\n' + '\n' + '\n' + f' {safe_title}\n' + f' {safe_author}\n' + f' {he(publisher or "")}\n' + f' {book_id}\n' + f' {now_str}\n' + ' nl\n' + "\n" + "\n" + ' \n' + ' \n' + + ( + ' \n' + if has_break else "" + ) + + "\n".join(manifest_items) + + "\n\n" + '\n' + + "\n".join(spine_idrefs) + + "\n\n" + "\n" + ) + z.writestr("OEBPS/content.opf", opf) + + return buf.getvalue() diff --git a/containers/novela/main.py b/containers/novela/main.py index 9aba2f4..fdf7068 100644 --- a/containers/novela/main.py +++ b/containers/novela/main.py @@ -9,6 +9,7 @@ from migrations import run_migrations from routers.backup import start_backup_scheduler, stop_backup_scheduler from routers import ( backup_router, + builder_router, editor_router, grabber_router, library_router, @@ -38,6 +39,7 @@ app.include_router(editor_router) app.include_router(grabber_router) app.include_router(settings_router) app.include_router(backup_router) +app.include_router(builder_router) @app.get("/") diff --git a/containers/novela/migrations.py b/containers/novela/migrations.py index 7a95446..40919aa 100644 --- a/containers/novela/migrations.py +++ b/containers/novela/migrations.py @@ -261,6 +261,23 @@ def migrate_series_suffix() -> None: ) +def migrate_create_builder_drafts() -> None: + _exec( + """ + CREATE TABLE IF NOT EXISTS builder_drafts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(500) NOT NULL, + author VARCHAR(255) NOT NULL, + publisher VARCHAR(255) NOT NULL DEFAULT '', + source_url VARCHAR(1000) NOT NULL DEFAULT '', + chapters JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + """ + ) + + def run_migrations() -> None: migrate_create_library() migrate_create_book_tags() @@ -276,3 +293,4 @@ def run_migrations() -> None: migrate_remove_cover_missing_tag() migrate_create_bookmarks() migrate_series_suffix() + migrate_create_builder_drafts() diff --git a/containers/novela/routers/__init__.py b/containers/novela/routers/__init__.py index 4c1b8cb..3d13cb3 100644 --- a/containers/novela/routers/__init__.py +++ b/containers/novela/routers/__init__.py @@ -1,4 +1,5 @@ from routers.backup import router as backup_router +from routers.builder import router as builder_router from routers.editor import router as editor_router from routers.grabber import router as grabber_router from routers.library import router as library_router @@ -12,4 +13,5 @@ __all__ = [ "grabber_router", "backup_router", "settings_router", + "builder_router", ] diff --git a/containers/novela/routers/builder.py b/containers/novela/routers/builder.py new file mode 100644 index 0000000..1674b69 --- /dev/null +++ b/containers/novela/routers/builder.py @@ -0,0 +1,270 @@ +"""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": "

"} + 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}) diff --git a/containers/novela/static/builder.css b/containers/novela/static/builder.css new file mode 100644 index 0000000..19a806f --- /dev/null +++ b/containers/novela/static/builder.css @@ -0,0 +1,296 @@ +/* ── Wrapper ── */ +.builder-wrap { + margin-left: var(--sidebar, 220px); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Overzichtspagina ── */ +.builder-index { + padding: 2rem; + max-width: 640px; +} +.builder-index-title { + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 1.5rem; + color: var(--text); +} +.builder-create-card { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1.2rem 1.4rem; + margin-bottom: 2rem; +} +.builder-create-title { + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 1rem; +} +.builder-create-form { display: flex; flex-direction: column; gap: 0.7rem; } +.bc-row { display: flex; flex-direction: column; gap: 0.25rem; } +.bc-label { font-size: 0.78rem; color: var(--text-dim); } +.bc-required { color: #e07070; } +.bc-input { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + padding: 0.45rem 0.7rem; + font-size: 0.88rem; + outline: none; +} +.bc-input:focus { border-color: var(--accent); } +.bc-actions { margin-top: 0.4rem; } + +/* Drafts lijst */ +.builder-drafts-label { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 0.6rem; +} +.builder-drafts-list { display: flex; flex-direction: column; gap: 0.5rem; } +.draft-card { + display: flex; + align-items: baseline; + gap: 0.8rem; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.6rem 0.9rem; +} +.draft-card-title { + font-size: 0.9rem; + color: var(--text); + text-decoration: none; + flex: 1; +} +.draft-card-title:hover { text-decoration: underline; } +.draft-card-meta { font-size: 0.76rem; color: var(--text-dim); } +.draft-card-delete { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + font-size: 1rem; + padding: 0 0.2rem; +} +.draft-card-delete:hover { color: #e07070; } + +/* ── Editor layout ── */ +.builder-editor { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* Header */ +.builder-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 0 1rem; + height: 44px; + min-height: 44px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.builder-back { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + color: var(--text-dim); + text-decoration: none; +} +.builder-back:hover { color: var(--text); } +.builder-header-title { + flex: 1; + font-size: 0.9rem; + font-weight: 500; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.builder-header-actions { display: flex; align-items: center; gap: 0.7rem; } + +/* Toolbar */ +.builder-toolbar { + display: flex; + align-items: center; + gap: 0.2rem; + padding: 0.3rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--surface2); +} +.tb-btn { + background: none; + border: 1px solid transparent; + border-radius: 4px; + color: var(--text-dim); + cursor: pointer; + font-size: 0.82rem; + padding: 0.25rem 0.5rem; + line-height: 1.4; +} +.tb-btn:hover { + background: var(--surface); + border-color: var(--border); + color: var(--text); +} +.tb-sep { + width: 1px; + height: 16px; + background: var(--border); + margin: 0 0.3rem; +} +.tb-normalize { + margin-left: auto; + font-size: 0.78rem; + color: var(--accent); + border-color: var(--accent) !important; +} + +/* Body */ +.builder-body { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Chapter panel */ +.builder-chapter-panel { + width: 200px; + min-width: 200px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + background: var(--surface2); + overflow: hidden; +} +.chapter-panel-title { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dim); + padding: 0.6rem 0.9rem 0.3rem; +} +.chapter-list { flex: 1; overflow-y: auto; } +.chapter-item { + display: flex; + align-items: center; + padding: 0.4rem 0.9rem; + cursor: pointer; + border-left: 2px solid transparent; + gap: 0.3rem; +} +.chapter-item:hover { background: var(--surface); } +.chapter-item.active { + border-left-color: var(--accent); + background: var(--surface); +} +.chapter-item-title { + flex: 1; + font-size: 0.83rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chapter-item-delete { + background: none; + border: none; + color: transparent; + cursor: pointer; + font-size: 0.9rem; + padding: 0; +} +.chapter-item:hover .chapter-item-delete { color: var(--text-dim); } +.chapter-item-delete:hover { color: #e07070 !important; } +.btn-add-chapter { + background: none; + border: none; + border-top: 1px solid var(--border); + color: var(--text-dim); + cursor: pointer; + font-size: 0.82rem; + padding: 0.6rem 0.9rem; + text-align: left; + width: 100%; +} +.btn-add-chapter:hover { color: var(--text); } + +/* Editor pane */ +.builder-editor-pane { + flex: 1; + overflow-y: auto; + padding: 2rem 3rem; + background: var(--surface); +} +.builder-content { + max-width: 680px; + margin: 0 auto; + min-height: 60vh; + font-family: Georgia, serif; + font-size: 1rem; + line-height: 1.75; + color: var(--text); + outline: none; +} +.builder-content p { margin: 0 0 0.8em; } +.builder-content blockquote { + border-left: 3px solid var(--border); + margin: 1em 2em; + padding: 0.3em 0.8em; + color: var(--text-dim); +} +.builder-content blockquote.author-note { + font-style: italic; + color: var(--text-dim); + border-left-color: #555; + font-size: 0.93em; +} +.builder-content img.scene-break { + display: block; + margin: 1em auto; + height: 15px; +} + +/* Status */ +.save-status { font-size: 0.78rem; color: var(--text-dim); } +.save-status.dirty { color: var(--warning, #c8a03a); } +.save-status.saved { color: var(--success, #6baa6b); } +.save-status.error { color: var(--error, #c85a3a); } + +/* Knoppen */ +.btn-primary { + background: var(--accent); + border: none; + border-radius: 4px; + color: #fff; + cursor: pointer; + font-size: 0.83rem; + padding: 0.4rem 0.9rem; +} +.btn-primary:hover { opacity: 0.85; } +.btn-publish { + background: #4a7a4a; + border: none; + border-radius: 4px; + color: #fff; + cursor: pointer; + font-size: 0.83rem; + padding: 0.35rem 0.8rem; +} +.btn-publish:hover { background: #5a8a5a; } +.btn-publish:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/containers/novela/static/builder.js b/containers/novela/static/builder.js new file mode 100644 index 0000000..9948fd7 --- /dev/null +++ b/containers/novela/static/builder.js @@ -0,0 +1,298 @@ +// ── State ───────────────────────────────────────────────────────────────────── + +const { draftId } = BUILDER; +let chapters = BUILDER.chapters ? BUILDER.chapters.slice() : []; +let currentIdx = -1; +let isDirty = false; + +const AUTOSAVE_MS = 30_000; + +// ── Init ────────────────────────────────────────────────────────────────────── + +document.addEventListener('DOMContentLoaded', () => { + if (typeof BUILDER === 'undefined') { + // Overzichtspagina — bind verwijder-knoppen drafts + bindDraftDeleteButtons(); + return; + } + + if (chapters.length === 0) { + apiAddChapter('Hoofdstuk 1', -1); + } else { + renderChapterList(); + loadChapter(0); + } + + setInterval(() => { + if (isDirty && currentIdx >= 0) saveCurrentChapter({ silent: true }); + }, AUTOSAVE_MS); + + bindToolbar(); + + document.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + saveCurrentChapter(); + } + }); +}); + +// ── Toolbar ─────────────────────────────────────────────────────────────────── + +function bindToolbar() { + document.querySelectorAll('.tb-btn[data-cmd]').forEach(btn => { + btn.addEventListener('mousedown', e => { + e.preventDefault(); + document.execCommand(btn.dataset.cmd, false, null); + getEditor().focus(); + }); + }); +} + +function getEditor() { + return document.getElementById('builder-content'); +} + +// ── Status ──────────────────────────────────────────────────────────────────── + +function markDirty() { + isDirty = true; + setStatus('dirty', 'Niet-opgeslagen wijzigingen'); +} + +function setStatus(type, msg) { + const el = document.getElementById('save-status'); + if (!el) return; + el.textContent = msg; + el.className = 'save-status' + (type ? ' ' + type : ''); +} + +// ── Chapter list ────────────────────────────────────────────────────────────── + +function renderChapterList() { + const el = document.getElementById('chapter-list'); + el.innerHTML = ''; + chapters.forEach((ch, i) => { + const item = document.createElement('div'); + item.className = 'chapter-item' + (i === currentIdx ? ' active' : ''); + + const label = document.createElement('span'); + label.className = 'chapter-item-title'; + label.textContent = ch.title || `Hoofdstuk ${i + 1}`; + label.onclick = () => switchChapter(i); + + const del = document.createElement('button'); + del.className = 'chapter-item-delete'; + del.title = 'Hoofdstuk verwijderen'; + del.textContent = '×'; + del.onclick = e => { e.stopPropagation(); deleteChapter(i); }; + + item.appendChild(label); + item.appendChild(del); + el.appendChild(item); + }); +} + +// ── Load / switch ───────────────────────────────────────────────────────────── + +async function switchChapter(idx) { + if (idx === currentIdx) return; + if (isDirty) await saveCurrentChapter({ silent: true }); + loadChapter(idx); +} + +function loadChapter(idx) { + currentIdx = idx; + isDirty = false; + const ch = chapters[idx]; + const editor = getEditor(); + editor.innerHTML = ch.content || '


'; + editor.oninput = markDirty; + renderChapterList(); + setStatus('', ''); +} + +// ── Save ────────────────────────────────────────────────────────────────────── + +async function saveCurrentChapter({ silent = false } = {}) { + if (currentIdx < 0) return; + const content = getEditor().innerHTML; + const title = chapters[currentIdx].title; + + try { + const resp = await fetch(`/api/builder/${draftId}/chapter/${currentIdx}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, content }), + }); + if (resp.ok) { + chapters[currentIdx].content = content; + isDirty = false; + if (!silent) { + const now = new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' }); + setStatus('saved', `Opgeslagen om ${now}`); + } + } else { + setStatus('error', 'Opslaan mislukt'); + } + } catch { + setStatus('error', 'Netwerkfout bij opslaan'); + } +} + +// ── Add chapter ─────────────────────────────────────────────────────────────── + +async function addChapter() { + const title = prompt('Naam van het nieuwe hoofdstuk:', `Hoofdstuk ${chapters.length + 1}`); + if (!title) return; + if (isDirty) await saveCurrentChapter({ silent: true }); + await apiAddChapter(title, currentIdx); +} + +async function apiAddChapter(title, afterIndex) { + const resp = await fetch(`/api/builder/${draftId}/chapter`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, after_index: afterIndex }), + }); + if (!resp.ok) { setStatus('error', 'Hoofdstuk toevoegen mislukt'); return; } + const data = await resp.json(); + await refreshDraft(data.index); +} + +async function refreshDraft(targetIdx) { + const resp = await fetch(`/api/builder/${draftId}`); + if (!resp.ok) return; + const data = await resp.json(); + chapters = data.chapters; + renderChapterList(); + loadChapter(Math.min(targetIdx ?? currentIdx, chapters.length - 1)); +} + +// ── Delete chapter ──────────────────────────────────────────────────────────── + +async function deleteChapter(idx) { + if (chapters.length <= 1) { alert('Kan het laatste hoofdstuk niet verwijderen.'); return; } + if (!confirm(`Hoofdstuk "${chapters[idx].title}" verwijderen?`)) return; + const resp = await fetch(`/api/builder/${draftId}/chapter/${idx}`, { method: 'DELETE' }); + if (!resp.ok) { setStatus('error', 'Verwijderen mislukt'); return; } + const data = await resp.json(); + await refreshDraft(data.index); +} + +// ── Toolbar acties ──────────────────────────────────────────────────────────── + +function insertBreak() { + const editor = getEditor(); + editor.focus(); + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return; + + const range = sel.getRangeAt(0); + range.deleteContents(); + + const img = document.createElement('img'); + img.src = '/static/break.png'; + img.style.height = '15px'; + img.className = 'scene-break'; + img.alt = '* * *'; + + const center = document.createElement('center'); + center.appendChild(img); + + const p = document.createElement('p'); + p.innerHTML = '
'; + + range.insertNode(p); + range.insertNode(center); + + const newRange = document.createRange(); + newRange.setStart(p, 0); + newRange.collapse(true); + sel.removeAllRanges(); + sel.addRange(newRange); + + markDirty(); +} + +function wrapBlockquote(cssClass) { + const editor = getEditor(); + editor.focus(); + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return; + + const range = sel.getRangeAt(0); + const selectedText = range.toString(); + if (!selectedText.trim()) return; + + const bq = document.createElement('blockquote'); + if (cssClass) bq.className = cssClass; + + const p = document.createElement('p'); + p.textContent = selectedText; + bq.appendChild(p); + + range.deleteContents(); + range.insertNode(bq); + markDirty(); +} + +// ── Normaliseer ─────────────────────────────────────────────────────────────── + +async function normalizeChapter() { + if (currentIdx < 0) return; + const rawHtml = getEditor().innerHTML; + + const resp = await fetch(`/api/builder/${draftId}/normalize/${currentIdx}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: rawHtml }), + }); + if (!resp.ok) { setStatus('error', 'Normaliseren mislukt'); return; } + const data = await resp.json(); + + if (!confirm('Normalisatie toepassen? Huidige inhoud wordt overschreven.')) return; + getEditor().innerHTML = data.content; + markDirty(); + await saveCurrentChapter({ silent: true }); + setStatus('saved', 'Genormaliseerd en opgeslagen'); +} + +// ── Publiceer ───────────────────────────────────────────────────────────────── + +async function publishDraft() { + if (isDirty) await saveCurrentChapter({ silent: true }); + if (!confirm('Dit boek publiceren en aan de library toevoegen?')) return; + + const btn = document.getElementById('btn-publish'); + btn.disabled = true; + setStatus('', 'Bezig met publiceren…'); + + try { + const resp = await fetch(`/api/builder/${draftId}/publish`, { method: 'POST' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + setStatus('error', err.error || 'Publiceren mislukt'); + btn.disabled = false; + return; + } + const data = await resp.json(); + window.location.href = `/library/book/${encodeURIComponent(data.filename)}`; + } catch { + setStatus('error', 'Netwerkfout bij publiceren'); + btn.disabled = false; + } +} + +// ── Draft verwijderen (overzichtspagina) ────────────────────────────────────── + +function bindDraftDeleteButtons() { + document.querySelectorAll('.draft-card-delete').forEach(btn => { + btn.addEventListener('click', async () => { + const id = btn.dataset.id; + if (!confirm('Draft verwijderen?')) return; + await fetch(`/api/builder/${id}`, { method: 'DELETE' }); + btn.closest('.draft-card').remove(); + }); + }); +} diff --git a/containers/novela/static/epub-style.css b/containers/novela/static/epub-style.css index 1a05a41..84b1b49 100644 --- a/containers/novela/static/epub-style.css +++ b/containers/novela/static/epub-style.css @@ -252,3 +252,13 @@ span.chat b { } } +/* Author note — blockquote voor commentaar van de auteur buiten de hoofdtekst */ +blockquote.author-note { + font-style: italic; + color: #888; + border-left: 3px solid #555; + margin: 1.2em 2em; + padding: 0.4em 1em; + font-size: 0.92em; +} + diff --git a/containers/novela/templates/_sidebar.html b/containers/novela/templates/_sidebar.html index 5c3cbd4..47d6c1e 100644 --- a/containers/novela/templates/_sidebar.html +++ b/containers/novela/templates/_sidebar.html @@ -153,6 +153,14 @@ Convert +
  • + + + + + Book Builder + +
  • diff --git a/containers/novela/templates/builder.html b/containers/novela/templates/builder.html new file mode 100644 index 0000000..5c13a2b --- /dev/null +++ b/containers/novela/templates/builder.html @@ -0,0 +1,129 @@ + + + + + + Novela — Book Builder{% if view == 'editor' %}: {{ draft.title }}{% endif %} + + + + + + +{% include "_sidebar.html" %} + +
    + +{% if view == 'index' %} + + +{% elif view == 'editor' %} +
    + +
    + + + + + Drafts + +
    {{ draft.title }}
    +
    + + +
    +
    + +
    + + + +
    + + +
    + +
    + +
    + +
    + +
    +
    +
    +
    +
    + + + +{% endif %} + +
    + + diff --git a/containers/novela/xhtml.py b/containers/novela/xhtml.py index 0db7fde..fd286de 100644 --- a/containers/novela/xhtml.py +++ b/containers/novela/xhtml.py @@ -167,3 +167,87 @@ def element_to_xhtml(el, break_img_path: str = "../Images/break.png", empty_p_is for c in el.children: parts.append(element_to_xhtml(c, break_img_path, empty_p_is_spacer)) return "".join(parts) + + +def normalize_wysiwyg_html(raw_html: str, break_img_path: str = "../Images/break.png") -> str: + """Normaliseer HTML uit de WYSIWYG-editor naar EPUB-compatibele XHTML. + + Vervangt scene-breaks door de break-afbeelding, behoudt , , , +
    ,
    , wikkelt losse tekst in

    , + verwijdert lege paragrafen. + """ + from bs4 import BeautifulSoup, NavigableString, Tag + + soup = BeautifulSoup(raw_html or "", "html.parser") + body = soup.find("body") or soup + output_parts: list[str] = [] + + def process_inline(el) -> str: + if isinstance(el, NavigableString): + text = str(el) + return he(text) if text else "" + if el.name in ("strong", "b"): + inner = "".join(process_inline(c) for c in el.children) + return f"{inner}" + if el.name in ("em", "i"): + inner = "".join(process_inline(c) for c in el.children) + return f"{inner}" + if el.name == "u": + inner = "".join(process_inline(c) for c in el.children) + return f"{inner}" + if el.name == "br": + return "
    " + return "".join(process_inline(c) for c in el.children) + + def process_block(el) -> str | None: + if isinstance(el, NavigableString): + text = str(el).strip() + return f"

    {he(text)}

    " if text else None + if not isinstance(el, Tag): + return None + if is_break_element(el): + return f'
    ' + if el.name == "img": + src = el.get("src", "") + alt = he(el.get("alt", "")) + if "break" in src.lower(): + return f'
    ' + return f'{alt}' if src else None + if el.name in ("p", "div"): + if is_break_element(el): + return f'
    ' + inner = "".join(process_inline(c) for c in el.children).strip() + return f"

    {inner}

    " if inner else None + if el.name in ("h1", "h2", "h3", "h4"): + inner = "".join(process_inline(c) for c in el.children).strip() + return f"<{el.name}>{inner}" if inner else None + if el.name == "blockquote": + classes = el.get("class", []) + css_class = " ".join(classes) if classes else "" + tag_open = f'
    ' if css_class else "
    " + parts = [] + for child in el.children: + if isinstance(child, NavigableString): + text = str(child).strip() + if text: + parts.append(f"

    {he(text)}

    ") + elif isinstance(child, Tag) and child.name in ("p", "div"): + inner = "".join(process_inline(c) for c in child.children).strip() + if inner: + parts.append(f"

    {inner}

    ") + else: + r = process_block(child) + if r: + parts.append(r) + return f"{tag_open}{''.join(parts)}
    " if parts else None + if el.name == "hr": + return f'
    ' + parts = [r for c in el.children if (r := process_block(c))] + return "".join(parts) if parts else None + + for child in list(body.children if hasattr(body, "children") else []): + result = process_block(child) + if result: + output_parts.append(result) + + return "\n".join(output_parts) diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index a7720b6..84db73c 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -150,6 +150,20 @@ Home read sections are ordered oldest-first: - `DELETE /api/break-patterns/{id}` — delete pattern - `DELETE /api/reading-history` — wipe all reading sessions +### `routers/builder.py` +- `GET /builder` — Book Builder index (draft list + new draft form) +- `POST /builder` — create new draft; redirects to `/builder/{id}` +- `GET /builder/{draft_id}` — draft editor page +- `DELETE /api/builder/{draft_id}` — delete draft +- `GET /api/builder/{draft_id}` — draft JSON (id, title, author, publisher, source_url, chapters) +- `POST /api/builder/{draft_id}/chapter` — add chapter `{title, after_index}`; returns `{index, count}` +- `PUT /api/builder/{draft_id}/chapter/{idx}` — save chapter `{title?, content?}` +- `DELETE /api/builder/{draft_id}/chapter/{idx}` — delete chapter; returns `{index, count}` +- `POST /api/builder/{draft_id}/normalize/{idx}` — normalize chapter HTML (preview only, does not save); returns `{content}` +- `POST /api/builder/{draft_id}/publish` — normalize all chapters → `build_epub()` → write to `library/epub/` → `upsert_book()` → delete draft; returns `{filename}`; redirects browser to `/library/book/{filename}` + +Publish flow: all chapters are run through `normalize_wysiwyg_html()`, then `build_epub()` produces an EPUB 2.0 ZIP. The file path is computed via `make_rel_path(media_type="epub", …)`. The book is inserted into the library with `needs_review=True`. The draft is deleted on success. + ### `routers/backup.py` - `GET /backup` — backup page - `GET /api/backup/credentials` — Dropbox settings (includes `app_key_configured` flag) @@ -240,6 +254,7 @@ Dropbox settings are managed via the web UI on `/backup`. - `Edit EPUB` button in Book Detail is only shown for `.epub` files. - Backup page supports: manual run, dry-run, Dropbox root, retention count, schedule (on/off + hours), status + history. - Bookmarks: saved per book via `POST /library/bookmarks/{filename}`; shown in Library sidebar section; navigated via `?bm_ch=N&bm_scroll=F` URL params on reader page. +- Book Builder (`/builder`): create EPUB books from scratch; drafts stored in `builder_drafts` (JSONB chapters); contenteditable editor with toolbar (bold/italic/underline/blockquote/author-note/scene-break/normalize); autosave every 30 s + Ctrl+S; publish normalizes HTML via `normalize_wysiwyg_html()` and builds EPUB via `build_epub()`. --- diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md index 8978077..747d11a 100644 --- a/docs/changelog-develop.md +++ b/docs/changelog-develop.md @@ -3,6 +3,19 @@ This file tracks changes on the `develop` line. `changelog.md` can later be used for release summaries. +## 2026-03-26 (2) +- Fixed Book Builder page showing white background: `library.css` added to `builder.html` to load `:root` CSS variables and dark `body` background; all CSS variable references in `builder.css` aligned with library theme names (`--text`, `--surface`, `--surface2`, `--text-dim`, `--border`, `--accent`, `--sidebar`) + +## 2026-03-26 (1) +- Added Book Builder: create EPUB books manually from scratch via a WYSIWYG editor + - New `builder_drafts` table (`id UUID`, `title`, `author`, `publisher`, `source_url`, `chapters JSONB`) + - `build_epub()` in `epub.py`: builds a standards-compliant EPUB 2.0 ZIP from title/author/publisher/chapters; embeds inline CSS and `break.png` if present + - `normalize_wysiwyg_html()` in `xhtml.py`: converts contenteditable HTML to EPUB-safe XHTML; handles scene-breaks, `
    `, inline formatting, and `
    ` → break image + - `routers/builder.py`: draft CRUD, chapter CRUD (`GET/POST/PUT/DELETE`), normalize preview endpoint, publish endpoint (normalizes all chapters → builds EPUB → writes to `library/epub/` → upserts to DB → deletes draft → redirects to book detail) + - `templates/builder.html` + `static/builder.js` + `static/builder.css`: index page (new draft form + draft list) and editor (chapter panel, contenteditable pane, toolbar with bold/italic/underline/blockquote/author-note/scene-break/normalize, autosave every 30 s, Ctrl+S, publish button) + - Book Builder link added to sidebar Tools section (between Convert and Credentials) + - `.author-note` blockquote style added to `static/epub-style.css` + ## 2026-03-25 (20) - Added Rated section to library sidebar: shows all non-archived books with `rating > 0`, sorted by rating descending then title alphabetically; badge displays total count; navigable via `#rated` URL hash