Merge branch v20260326-02-bookbuilder into main

This commit is contained in:
Ivo Oskamp 2026-04-03 15:15:03 +02:00
commit 9b7ac7213d
13 changed files with 1291 additions and 0 deletions

View File

@ -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",
'<?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 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("/")

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:
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()

View File

@ -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",
]

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
</a>
</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>
<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">

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:
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 <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/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()`.
---

View File

@ -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, `<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)
- 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