Merge branch v20260326-02-bookbuilder into main
This commit is contained in:
commit
9b7ac7213d
@ -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()
|
||||
|
||||
@ -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("/")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
270
containers/novela/routers/builder.py
Normal file
270
containers/novela/routers/builder.py
Normal 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})
|
||||
296
containers/novela/static/builder.css
Normal file
296
containers/novela/static/builder.css
Normal 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; }
|
||||
298
containers/novela/static/builder.js
Normal file
298
containers/novela/static/builder.js
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
129
containers/novela/templates/builder.html
Normal file
129
containers/novela/templates/builder.html
Normal 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 }} · {{ d.updated_at.strftime('%d %b %Y %H:%M') }}</div>
|
||||
<button class="draft-card-delete" data-id="{{ d.id }}" title="Draft verwijderen">✕</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('')">❝</button>
|
||||
<button class="tb-btn" title="Auteur-noot" onclick="wrapBlockquote('author-note')">✍</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>
|
||||
@ -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)
|
||||
|
||||
@ -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()`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user