Add Book Builder: WYSIWYG EPUB editor with draft management and publish flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ivo Oskamp 2026-03-26 10:24:57 +01:00
parent 1b885e1873
commit 2d672ff7bc
13 changed files with 1291 additions and 0 deletions

View File

@ -1,7 +1,9 @@
import io import io
import re import re
import zipfile import zipfile
from datetime import datetime, timezone
from html import escape as he from html import escape as he
from pathlib import Path
def detect_image_format(data: bytes, base: str) -> tuple[str, str]: 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: with open(epub_path, "wb") as f:
f.write(buf.getvalue()) 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",
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n'
' <rootfiles>\n'
' <rootfile full-path="OEBPS/content.opf"'
' media-type="application/oebps-package+xml"/>\n'
' </rootfiles>\n'
'</container>\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 "<p></p>"
xhtml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"\n'
' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n'
'<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="nl">\n'
"<head>\n"
' <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>\n'
f" <title>{ch_title}</title>\n"
' <link rel="stylesheet" type="text/css" href="../Styles/style.css"/>\n'
"</head>\n"
"<body>\n"
f" <h2 class=\"chapter-title\">{ch_title}</h2>\n"
f" {ch_content}\n"
"</body>\n"
"</html>\n"
)
z.writestr(ch_filename, xhtml)
manifest_items.append(
f' <item id="{ch_id}" href="Text/{ch_id}.xhtml"'
f' media-type="application/xhtml+xml"/>'
)
spine_idrefs.append(f' <itemref idref="{ch_id}"/>')
ncx_nav_points.append(
f' <navPoint id="navPoint-{i + 1}" playOrder="{i + 2}">\n'
f' <navLabel><text>{ch_title}</text></navLabel>\n'
f' <content src="Text/{ch_id}.xhtml"/>\n'
f' </navPoint>'
)
safe_title = he(title)
safe_author = he(author)
ncx = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN"\n'
' "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">\n'
'<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">\n'
"<head>\n"
f' <meta name="dtb:uid" content="{book_id}"/>\n'
' <meta name="dtb:depth" content="1"/>\n'
' <meta name="dtb:totalPageCount" content="0"/>\n'
' <meta name="dtb:maxPageNumber" content="0"/>\n'
"</head>\n"
f"<docTitle><text>{safe_title}</text></docTitle>\n"
f"<docAuthor><text>{safe_author}</text></docAuthor>\n"
"<navMap>\n"
+ "\n".join(ncx_nav_points)
+ "\n</navMap>\n</ncx>\n"
)
z.writestr("OEBPS/toc.ncx", ncx)
has_break = break_png_path.exists()
opf = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<package xmlns="http://www.idpf.org/2007/opf" version="2.0"'
f' unique-identifier="BookId">\n'
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"'
' xmlns:opf="http://www.idpf.org/2007/opf">\n'
f' <dc:title>{safe_title}</dc:title>\n'
f' <dc:creator opf:role="aut">{safe_author}</dc:creator>\n'
f' <dc:publisher>{he(publisher or "")}</dc:publisher>\n'
f' <dc:identifier id="BookId" opf:scheme="UUID">{book_id}</dc:identifier>\n'
f' <dc:date>{now_str}</dc:date>\n'
' <dc:language>nl</dc:language>\n'
"</metadata>\n"
"<manifest>\n"
' <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>\n'
' <item id="style" href="Styles/style.css" media-type="text/css"/>\n'
+ (
' <item id="break-img" href="Images/break.png" media-type="image/png"/>\n'
if has_break else ""
)
+ "\n".join(manifest_items)
+ "\n</manifest>\n"
'<spine toc="ncx">\n'
+ "\n".join(spine_idrefs)
+ "\n</spine>\n"
"</package>\n"
)
z.writestr("OEBPS/content.opf", opf)
return buf.getvalue()

View File

@ -9,6 +9,7 @@ from migrations import run_migrations
from routers.backup import start_backup_scheduler, stop_backup_scheduler from routers.backup import start_backup_scheduler, stop_backup_scheduler
from routers import ( from routers import (
backup_router, backup_router,
builder_router,
editor_router, editor_router,
grabber_router, grabber_router,
library_router, library_router,
@ -38,6 +39,7 @@ app.include_router(editor_router)
app.include_router(grabber_router) app.include_router(grabber_router)
app.include_router(settings_router) app.include_router(settings_router)
app.include_router(backup_router) app.include_router(backup_router)
app.include_router(builder_router)
@app.get("/") @app.get("/")

View File

@ -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: def run_migrations() -> None:
migrate_create_library() migrate_create_library()
migrate_create_book_tags() migrate_create_book_tags()
@ -276,3 +293,4 @@ def run_migrations() -> None:
migrate_remove_cover_missing_tag() migrate_remove_cover_missing_tag()
migrate_create_bookmarks() migrate_create_bookmarks()
migrate_series_suffix() migrate_series_suffix()
migrate_create_builder_drafts()

View File

@ -1,4 +1,5 @@
from routers.backup import router as backup_router 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.editor import router as editor_router
from routers.grabber import router as grabber_router from routers.grabber import router as grabber_router
from routers.library import router as library_router from routers.library import router as library_router
@ -12,4 +13,5 @@ __all__ = [
"grabber_router", "grabber_router",
"backup_router", "backup_router",
"settings_router", "settings_router",
"builder_router",
] ]

View File

@ -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": "<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})

View File

@ -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; }

View File

@ -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 || '<p><br></p>';
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 = '<br>';
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();
});
});
}

View File

@ -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;
}

View File

@ -153,6 +153,14 @@
Convert Convert
</a> </a>
</li> </li>
<li>
<a href="/builder"{% if active == 'builder' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
Book Builder
</a>
</li>
<li> <li>
<a href="/credentials-manager"{% if active == 'credentials' %} class="active"{% endif %}> <a href="/credentials-manager"{% if active == 'credentials' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">

View File

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Book Builder{% if view == 'editor' %}: {{ draft.title }}{% endif %}</title>
<link rel="stylesheet" href="/static/library.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<link rel="stylesheet" href="/static/builder.css"/>
</head>
<body>
{% include "_sidebar.html" %}
<div class="builder-wrap">
{% if view == 'index' %}
<div class="builder-index">
<div class="builder-index-header">
<h1 class="builder-index-title">Book Builder</h1>
</div>
<div class="builder-create-card">
<div class="builder-create-title">Nieuw boek</div>
<form method="post" action="/builder" class="builder-create-form">
<div class="bc-row">
<label class="bc-label">Titel <span class="bc-required">*</span></label>
<input class="bc-input" type="text" name="title" required placeholder="Boektitel" autocomplete="off"/>
</div>
<div class="bc-row">
<label class="bc-label">Auteur <span class="bc-required">*</span></label>
<input class="bc-input" type="text" name="author" required placeholder="Voornaam Achternaam" autocomplete="off"/>
</div>
<div class="bc-row">
<label class="bc-label">Publisher</label>
<input class="bc-input" type="text" name="publisher" placeholder="Optioneel" autocomplete="off"/>
</div>
<div class="bc-row">
<label class="bc-label">Source URL</label>
<input class="bc-input" type="url" name="source_url" placeholder="https://…" autocomplete="off"/>
</div>
<div class="bc-actions">
<button class="btn-primary" type="submit">Aanmaken</button>
</div>
</form>
</div>
{% if drafts %}
<div class="builder-drafts-section">
<div class="builder-drafts-label">Openstaande drafts</div>
<div class="builder-drafts-list">
{% for d in drafts %}
<div class="draft-card">
<a class="draft-card-title" href="/builder/{{ d.id }}">{{ d.title }}</a>
<div class="draft-card-meta">{{ d.author }} &middot; {{ d.updated_at.strftime('%d %b %Y %H:%M') }}</div>
<button class="draft-card-delete" data-id="{{ d.id }}" title="Draft verwijderen">&#x2715;</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% elif view == 'editor' %}
<div class="builder-editor" id="builder-editor">
<div class="builder-header">
<a class="builder-back" href="/builder">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6"/>
</svg>
Drafts
</a>
<div class="builder-header-title">{{ draft.title }}</div>
<div class="builder-header-actions">
<span class="save-status" id="save-status"></span>
<button class="btn-publish" id="btn-publish" onclick="publishDraft()">Publiceer als EPUB</button>
</div>
</div>
<div class="builder-toolbar" id="builder-toolbar">
<button class="tb-btn" data-cmd="bold" title="Vet (Ctrl+B)"><strong>B</strong></button>
<button class="tb-btn" data-cmd="italic" title="Cursief (Ctrl+I)"><em>I</em></button>
<button class="tb-btn" data-cmd="underline" title="Onderstrepen (Ctrl+U)"><u>U</u></button>
<div class="tb-sep"></div>
<button class="tb-btn" title="Citaat" onclick="wrapBlockquote('')">&#x275D;</button>
<button class="tb-btn" title="Auteur-noot" onclick="wrapBlockquote('author-note')">&#x270D;</button>
<div class="tb-sep"></div>
<button class="tb-btn" title="Scene break" onclick="insertBreak()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="12" x2="9" y2="12"/>
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none"/>
<line x1="15" y1="12" x2="21" y2="12"/>
</svg>
</button>
<div class="tb-sep"></div>
<button class="tb-btn tb-normalize" onclick="normalizeChapter()" title="Normaliseer huidige hoofdstuk">Normaliseer</button>
</div>
<div class="builder-body">
<nav class="builder-chapter-panel">
<div class="chapter-panel-title">Hoofdstukken</div>
<div class="chapter-list" id="chapter-list"></div>
<button class="btn-add-chapter" onclick="addChapter()">+ Hoofdstuk</button>
</nav>
<div class="builder-editor-pane">
<div
class="builder-content"
id="builder-content"
contenteditable="true"
spellcheck="true"
></div>
</div>
</div>
</div>
<script>
const BUILDER = {
draftId: {{ draft.id | tojson }},
title: {{ draft.title | tojson }},
chapters: {{ draft.chapters | tojson }},
};
</script>
<script src="/static/builder.js"></script>
{% endif %}
</div>
</body>
</html>

View File

@ -167,3 +167,87 @@ def element_to_xhtml(el, break_img_path: str = "../Images/break.png", empty_p_is
for c in el.children: for c in el.children:
parts.append(element_to_xhtml(c, break_img_path, empty_p_is_spacer)) parts.append(element_to_xhtml(c, break_img_path, empty_p_is_spacer))
return "".join(parts) 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 <strong>, <em>, <u>,
<blockquote>, <blockquote class="author-note">, wikkelt losse tekst in <p>,
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"<strong>{inner}</strong>"
if el.name in ("em", "i"):
inner = "".join(process_inline(c) for c in el.children)
return f"<em>{inner}</em>"
if el.name == "u":
inner = "".join(process_inline(c) for c in el.children)
return f"<u>{inner}</u>"
if el.name == "br":
return "<br />"
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"<p>{he(text)}</p>" if text else None
if not isinstance(el, Tag):
return None
if is_break_element(el):
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
if el.name == "img":
src = el.get("src", "")
alt = he(el.get("alt", ""))
if "break" in src.lower():
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
return f'<img src="{he(src)}" alt="{alt}"/>' if src else None
if el.name in ("p", "div"):
if is_break_element(el):
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
inner = "".join(process_inline(c) for c in el.children).strip()
return f"<p>{inner}</p>" 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}</{el.name}>" if inner else None
if el.name == "blockquote":
classes = el.get("class", [])
css_class = " ".join(classes) if classes else ""
tag_open = f'<blockquote class="{css_class}">' if css_class else "<blockquote>"
parts = []
for child in el.children:
if isinstance(child, NavigableString):
text = str(child).strip()
if text:
parts.append(f"<p>{he(text)}</p>")
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"<p>{inner}</p>")
else:
r = process_block(child)
if r:
parts.append(r)
return f"{tag_open}{''.join(parts)}</blockquote>" if parts else None
if el.name == "hr":
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
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)

View File

@ -150,6 +150,20 @@ Home read sections are ordered oldest-first:
- `DELETE /api/break-patterns/{id}` — delete pattern - `DELETE /api/break-patterns/{id}` — delete pattern
- `DELETE /api/reading-history` — wipe all reading sessions - `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` ### `routers/backup.py`
- `GET /backup` — backup page - `GET /backup` — backup page
- `GET /api/backup/credentials` — Dropbox settings (includes `app_key_configured` flag) - `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. - `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. - 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. - 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()`.
--- ---

View File

@ -3,6 +3,19 @@
This file tracks changes on the `develop` line. This file tracks changes on the `develop` line.
`changelog.md` can later be used for release summaries. `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, `<blockquote class="author-note">`, inline formatting, and `<hr>` → 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) ## 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 - 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