Compare commits

..

31 Commits

Author SHA1 Message Date
f8bae60ac7 Merge branch v20260403-01 into main 2026-04-03 15:15:20 +02:00
d8d30fb00d Merge branch v20260331-01 into main 2026-04-03 15:15:15 +02:00
26c6c151c6 Merge branch v20260329-01 into main 2026-04-03 15:15:11 +02:00
f4ac7a7662 Merge branch v20260327-01 into main 2026-04-03 15:15:07 +02:00
9b7ac7213d Merge branch v20260326-02-bookbuilder into main 2026-04-03 15:15:03 +02:00
25ececc576 Merge branch v20260326-01 into main 2026-04-03 15:15:00 +02:00
ef3c28b141 Merge branch v20260325c into main 2026-04-03 15:14:55 +02:00
eb4136afc7 Merge branch v20260325b into main 2026-04-03 15:14:51 +02:00
32bf4a4d83 Merge branch v20260325 into main 2026-04-03 15:14:48 +02:00
e4d2e2c636 DB-stored books, full-text search, backup restore, and AO3 scraper
- DB-stored books (Fase 1–6): chapters and images stored in PostgreSQL; grabber writes to DB, EPUB→DB conversion, DB→EPUB export, FTS search page (/search)
- Chapter editor: Monaco editor supports DB-stored books; inline title editing
- Grabber: DB/EPUB storage toggle on Convert page
- Backup: restore from Dropbox snapshot (browse snapshots, restore individual or selected files)
- AO3 scraper: initial implementation
- Changelog: v0.1.2 and v0.1.3 entries added to changelog.py and changelog.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:13:08 +02:00
4ecbfafc86 Release v0.1.3 on branch v20260331-01 (bump type 1) 2026-04-03 15:11:27 +02:00
fda690596c Release v0.1.2 on branch v20260331-01 (bump type 1) 2026-04-02 23:44:44 +02:00
fab7591f38 Release v0.1.1 on branch v20260331-01 (bump type 1) 2026-03-31 20:07:38 +02:00
e1aca546a0 Release v0.1.3 on branch v20260331-01 (bump type 1) 2026-03-31 20:05:28 +02:00
fb8311fb3f Release v0.1.2 on branch v20260331-01 (bump type 1) 2026-03-31 20:03:28 +02:00
b0cb365f98 Add Changelog page, shared JS/CSS, branding, volume-aware duplicates, and CBR improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:03:18 +02:00
b70379c5b9 Release v0.1.1 on branch v20260329-01 (bump type 1) 2026-03-31 19:52:25 +02:00
b9b7fb3009 Release v0.1.2 on branch v20260329-01 (bump type 1) 2026-03-31 19:50:14 +02:00
0a46e1c13d Release v0.1.1 on branch v20260329-01 (bump type 1) 2026-03-31 19:49:43 +02:00
d012fa239e Release v0.1.0 on branch v20260329-01 (bump type 2) 2026-03-29 16:55:34 +02:00
5dfc99e8c0 Reset version to v0.0.9 to allow clean v0.1.0 release 2026-03-29 16:55:28 +02:00
6d2ca2eeb2 Release v0.1.1 on branch v20260329-01 (bump type 1) 2026-03-29 16:49:59 +02:00
b43366723c Add Bulk Import, Following, Incomplete, status overhaul, performance, and CBR fixes
- Bulk Import page: filename pattern parsing, shared metadata, duplicate detection (volume-aware), batch upload with progress
- Following page: track external author URLs; authors table; sidebar counter
- Incomplete view: non-archived books with publication_status ≠ Complete
- Status: added Temporary Hold, renamed Hiatus → Long-Term Hold; statusBadgeHtml() helper
- Status/want-to-read badges: dark fill + ring for readability on any cover colour
- Disk usage warning in sidebar (amber/red thresholds)
- Bulk delete batched via POST /library/bulk-delete
- CBR: magic bytes format detection + py7zr 7-zip support; unrar → proprietary unrar v6
- Performance: IntersectionObserver lazy covers, ETag 304, single DOM pass, json_agg tags
- Duplicate detection in library and Convert page warning
- All books Grid/List toggle; star ratings; reader text colour presets; bookmarks
- Docs: TECHNICAL.md and changelog updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:20:25 +02:00
3d739b4c72 Docs: update TECHNICAL.md and changelog for Following, Incomplete, and performance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 01:08:25 +01:00
5d83bfccab Performance: lazy covers, ETag caching, single DOM pass, SQL tag aggregation
- IntersectionObserver defers both cover images and placeholder canvas
  drawing until cards enter viewport — eliminates 1000+ upfront ops
- ETag on /library/list: browser gets 304 Not Modified when nothing changed
- Single DOM pass in renderBooksGrid/renderDuplicatesView/renderSeriesDetail:
  card.querySelector replaces second iteration with 500+ getElementById calls
- book_tags joined via json_agg in main query, removing separate SELECT + Python merge
- loadLibrary: error handling prevents silent failures showing as infinite loading
- Delete TODO-PERF-library-load.md (all four bottlenecks resolved)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 01:04:32 +01:00
00e75a6106 Add duplicate detection, Convert warning, and performance TODO
- Convert: warn when title+author already exists in library (preload check)
- Library: Duplicates sidebar section with grouped view and live counter
- Fix: Duplicates view cover loading now uses same canvas/two-pass pattern as renderBooksGrid
- Docs: add TODO-PERF-library-load.md with four identified bottlenecks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:22:02 +01:00
2d672ff7bc Add Book Builder: WYSIWYG EPUB editor with draft management and publish flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:24:57 +01:00
1b885e1873 Add bookmarks, bulk delete, series suffixes, CBR/CBZ reader, Dropbox OAuth2, backup progress, autocomplete, and path migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:42:56 +01:00
39eef0a388 Add publisher to PDF and CBR/CBZ storage paths
All formats now use {publisher}/{author} consistently:
- pdf/{publisher}/{author}/{title}.pdf
- comics/{publisher}/{author}/{title}.cbr|cbz
Previously PDF and comics only had {author}, unlike EPUB.
Updated TECHNICAL.md path table accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:56:53 +01:00
f3f9d45d2b Fix CBZ path, add download endpoint, rewrite TECHNICAL.md
- Fix CBZ extension: common.make_rel_path now accepts ext param;
  CBZ files are stored as comics/{author}/{title}.cbz instead of .cbr;
  library.py import passes actual file suffix
- Add GET /download/{filename} endpoint (was 404, referenced in book.html)
- TECHNICAL.md fully rewritten: File Storage Paths section with exact
  path patterns per format, complete endpoint lists for all routers
  including settings.py (previously undocumented), metadata save
  behaviour table, updated Known Bugs Fixed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:53:50 +01:00
92cd301658 Add PDF reader/editor support, fix metadata save and dir cleanup
- PDF reader: page-image rendering via /library/pdf/{filename}?page=N;
  new /api/pdf/info/{filename} endpoint returns page count; reader.html
  branches on FORMAT (epub/pdf) injected by server
- PDF metadata edit: PATCH /library/book now updates DB for all formats;
  _sync_epub_metadata only called for .epub; non-EPUB formats skip file write
- Fix file path on metadata save: _make_rel_path now includes format prefix
  (epub/, pdf/, comics/) matching common.make_rel_path used during import;
  previously files were moved outside their format directory
- Fix empty dir cleanup: prune_empty_dirs always runs after successful
  metadata save, not only when file was moved
- Hide Edit EPUB button for non-EPUB files in book detail
- Docs: TECHNICAL.md and changelog-develop.md updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:01 +01:00
66 changed files with 9547 additions and 1099 deletions

View File

@ -1,20 +1,65 @@
# Novela # Novela
Novela is a self-hosted web application for managing and reading a personal digital library. Novela is a self-hosted web application for managing and reading a personal digital library.
It supports EPUB, PDF, and CBR/CBZ, with metadata editing, reading progress tracking, and Dropbox backups. It supports EPUB, PDF, and CBR/CBZ, with metadata editing, reading progress tracking, a web scraper/converter, and Dropbox backups.
## What Novela Provides ## What Novela Provides
- Library import and indexing for EPUB/PDF/CBR/CBZ
- Home dashboard with continue reading and unread/read sections ### Library
- Reader support for EPUB, PDF, and comics (CBR/CBZ) - Import and indexing for EPUB, PDF, CBR/CBZ
- Metadata editing (title, author, publisher, series, volume, tags, genres) - Drag-and-drop import from library page or home page
- `New` review workflow with list/grid view, column toggles, and bulk actions - Cover extraction and caching (EPUB, PDF first page, CBR/CBZ first page); manual cover upload for EPUB
- Reading analytics/statistics dashboard - Metadata editing: title, author, publisher, series, volume, tags, genres, sub-genres, star rating, publication status
- Dropbox backup with: - Publication statuses: Complete, Ongoing, Temporary Hold, Long-Term Hold
- versioned snapshots - Want-to-read flag and archived flag
- object deduplication - 15 star ratings (stored in EPUB OPF / CBZ ComicInfo.xml / DB)
- retention policy - Download individual files
- scheduled background runs
### Views and Navigation
- Home dashboard: continue reading, unread shorts/novels, read shorts/novels
- Library grid/list views with search (title, author, genre)
- Series view: grouped by series with volume order
- Duplicates view: groups books with matching title+author
- Incomplete view: books not marked Complete
- New view: recently imported books awaiting review; bulk "Remove from New" action
- Following page: track external URLs per author
### Bulk Import
- `/bulk-import` page: import multiple files at once with filename-based metadata parsing
- Free-text `%placeholder%` pattern editor (e.g. `%series% - %volume% - %title% - %year%`)
- Available placeholders: `%series%`, `%volume%`, `%title%`, `%year%`, `%month%`, `%day%`, `%author%`, `%publisher%`, `%ignore%`
- Colored chips: click to insert at cursor or drag onto the pattern input
- Shared metadata fields (author, publisher, status, genres, tags) override filename-parsed values
- Preview table with editable cells before importing
- Duplicate detection: checks title+author against existing library; duplicate rows highlighted, skipped by default
- Batched upload (5 files per request) with progress bar
- Batched bulk delete (20 files per request) with progress bar
### Reader
- EPUB reader: chapter navigation, configurable content width and text colour, bookmarks
- PDF reader: page-by-page rendering
- CBR/CBZ reader: page-by-page image rendering
- Reading progress saved per book (resume where you left off)
- Bookmarks with notes; listed in library sidebar section
### EPUB Tools
- EPUB editor (`/library/editor/{filename}`): edit chapter HTML in the browser
- Book Builder (`/builder`): create EPUB books from scratch with a WYSIWYG editor; publish directly to library
- Web Grabber (`/grabber`): scrape and convert web fiction to EPUB; site credentials management
- Converter (`/convert`): convert a URL to EPUB; duplicate detection before conversion
### Analytics
- Statistics dashboard: reads by month / day / hour, genre and publisher breakdowns, top books, read history
### Backups
- Dropbox backup: versioned snapshots with object-level deduplication and retention policy
- OAuth2 flow (preferred) or legacy access token
- Scheduled background backups (configurable interval)
- Live progress indicator during backup runs
- PostgreSQL dump included in each backup
### Sidebar
- Disk usage warning: amber ≥ 85% or < 2 GB free; red 95% or < 500 MB free
## Tech Stack ## Tech Stack
- FastAPI - FastAPI
@ -23,10 +68,10 @@ It supports EPUB, PDF, and CBR/CBZ, with metadata editing, reading progress trac
- Docker / Docker Compose style deployment - Docker / Docker Compose style deployment
## Repository Layout ## Repository Layout
- `containers/novela/` - application code (routers, templates, static assets, migrations) - `containers/novela/` application code (routers, templates, static assets, migrations)
- `stack/` - deployment stack files and environment configuration - `stack/` deployment stack files and environment configuration
- `docs/` - technical status and changelog documentation - `docs/` technical status and changelog documentation
- `build-and-push.sh` - helper script for container build/push - `build-and-push.sh` helper script for container build/push
## Quick Start (Development) ## Quick Start (Development)
1. Configure environment values in `stack/novela.env`. 1. Configure environment values in `stack/novela.env`.

View File

@ -3,14 +3,14 @@ set -euo pipefail
# ============================================================================ # ============================================================================
# build-and-push.sh # build-and-push.sh
# Location: repo root (e.g. /docker/develop/novela) # Location: repo root
# #
# Purpose: # Purpose:
# - Automatic version bump: # - Automatic version bump:
# 1 = patch, 2 = minor, 3 = major, t = test # 1 = patch, 2 = minor, 3 = major, t = test
# - Test builds: only update :dev (no commit/tag) # - Test builds: only update :dev (no commit/tag)
# - Release builds: update version.txt, commit, tag, push (to the current branch) # - Release builds: update version.txt, commit, tag, push (to the current branch)
# - Build & push Docker images for each service under ./containers/* # - Build & push Docker images for each service under ./compose/*
# - Preflight checks: Docker daemon up, logged in to registry, valid names/tags # - Preflight checks: Docker daemon up, logged in to registry, valid names/tags
# - Summary: show all images + tags built and pushed # - Summary: show all images + tags built and pushed
# - Branch visibility: # - Branch visibility:
@ -120,7 +120,7 @@ if [[ ! -d ".git" ]]; then
fi fi
if [[ ! -d "$COMPOSE_DIR" ]]; then if [[ ! -d "$COMPOSE_DIR" ]]; then
echo "[ERROR] '$COMPOSE_DIR' directory missing. Expected ./containers/<service>/ with a Dockerfile." echo "[ERROR] '$COMPOSE_DIR' directory missing. Expected ./compose/<service>/ with a Dockerfile."
exit 1 exit 1
fi fi
@ -235,10 +235,11 @@ for svc_path in "${services[@]}"; do
echo "============================================================" echo "============================================================"
echo "[INFO] Building ${svc} -> tags: ${NEW_VERSION}, latest" echo "[INFO] Building ${svc} -> tags: ${NEW_VERSION}, latest"
echo "============================================================" echo "============================================================"
docker build -t "${IMAGE_BASE}:${NEW_VERSION}" -t "${IMAGE_BASE}:dev" "$svc_path" docker build -t "${IMAGE_BASE}:${NEW_VERSION}" -t "${IMAGE_BASE}:latest" -t "${IMAGE_BASE}:dev" "$svc_path"
docker push "${IMAGE_BASE}:${NEW_VERSION}" docker push "${IMAGE_BASE}:${NEW_VERSION}"
docker push "${IMAGE_BASE}:latest"
docker push "${IMAGE_BASE}:dev" docker push "${IMAGE_BASE}:dev"
BUILT_IMAGES+=("${IMAGE_BASE}:${NEW_VERSION}" "${IMAGE_BASE}:dev") BUILT_IMAGES+=("${IMAGE_BASE}:${NEW_VERSION}" "${IMAGE_BASE}:latest" "${IMAGE_BASE}:dev")
else else
echo "============================================================" echo "============================================================"
echo "[INFO] Test build ${svc} -> tag: latest" echo "[INFO] Test build ${svc} -> tag: latest"

View File

@ -2,10 +2,11 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN echo "deb http://deb.debian.org/debian bookworm non-free" >> /etc/apt/sources.list \
&& apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
libmagic1 \ libmagic1 \
unrar-free \ unrar \
postgresql-client \ postgresql-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

@ -2,21 +2,39 @@ from io import BytesIO
from pathlib import Path from pathlib import Path
import zipfile import zipfile
import py7zr
import rarfile import rarfile
from PIL import Image, ImageOps from PIL import Image, ImageOps
SUPPORTED_IMG = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"} SUPPORTED_IMG = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
_MAGIC_RAR = b"Rar!\x1a\x07"
_MAGIC_ZIP = b"PK"
_MAGIC_7Z = b"7z\xbc\xaf\x27\x1c"
def _is_cbz(path: Path) -> bool: def _detect_format(path: Path) -> str:
return path.suffix.lower() == ".cbz" """Detect archive format by magic bytes, ignoring file extension."""
with open(path, "rb") as f:
header = f.read(8)
if header[:6] == _MAGIC_RAR:
return "rar"
if header[:2] == _MAGIC_ZIP:
return "zip"
if header[:6] == _MAGIC_7Z:
return "7z"
# Fallback: trust the extension
return "zip" if path.suffix.lower() == ".cbz" else "rar"
def cbr_page_list(path: Path) -> list[str]: def cbr_page_list(path: Path) -> list[str]:
if _is_cbz(path): fmt = _detect_format(path)
if fmt == "zip":
with zipfile.ZipFile(path) as zf: with zipfile.ZipFile(path) as zf:
names = [n for n in zf.namelist() if Path(n).suffix.lower() in SUPPORTED_IMG] names = [n for n in zf.namelist() if Path(n).suffix.lower() in SUPPORTED_IMG]
elif fmt == "7z":
with py7zr.SevenZipFile(path, mode="r") as zf:
names = [n for n in zf.getnames() if Path(n).suffix.lower() in SUPPORTED_IMG]
else: else:
with rarfile.RarFile(path) as rf: with rarfile.RarFile(path) as rf:
names = [n for n in rf.namelist() if Path(n).suffix.lower() in SUPPORTED_IMG] names = [n for n in rf.namelist() if Path(n).suffix.lower() in SUPPORTED_IMG]
@ -42,11 +60,17 @@ def cbr_get_page(path: Path, page_num: int) -> tuple[bytes, str]:
"bmp": "image/bmp", "bmp": "image/bmp",
}.get(ext, "image/jpeg") }.get(ext, "image/jpeg")
if _is_cbz(path): fmt = _detect_format(path)
if fmt == "zip":
with zipfile.ZipFile(path) as zf: with zipfile.ZipFile(path) as zf:
return zf.read(name), mime return zf.read(name), mime
with rarfile.RarFile(path) as rf: elif fmt == "7z":
return rf.read(name), mime with py7zr.SevenZipFile(path, mode="r") as zf:
data = zf.read(targets=[name])
return data[name].read(), mime
else:
with rarfile.RarFile(path) as rf:
return rf.read(name), mime
def cbr_cover_thumb(path: Path) -> bytes: def cbr_cover_thumb(path: Path) -> bytes:

View File

@ -0,0 +1,168 @@
"""
Changelog data for Novela
"""
CHANGELOG = [
{
"version": "v0.1.3",
"date": "2026-04-03",
"summary": "DB-stored books: chapters stored in PostgreSQL with full-text search, EPUB conversion, export, and a storage toggle in the grabber.",
"sections": [
{
"title": "New feature",
"type": "feature",
"changes": [
"DB-stored books: scraped books are now stored as chapters in PostgreSQL instead of EPUB files on disk — full-text search, content deduplication, and backup coverage are all handled automatically",
"Grabber stores chapters in book_chapters and images in a content-addressed imagestore (sha256-based, automatic deduplication across all books)",
"EPUB-to-DB conversion: Convert to DB button on any EPUB book detail page — extracts chapters, migrates all metadata and child rows (tags, progress, bookmarks, cover), removes the EPUB file",
"DB-to-EPUB export: Export EPUB button on DB-stored books — builds and streams a standards-compliant EPUB without writing a file to disk",
"Full-text search (/search): searches across all DB-stored chapter content via PostgreSQL FTS (tsvector / plainto_tsquery), returns highlighted snippets with direct links to the chapter position in the reader",
"Chapter editor supports DB-stored books: Monaco-based editor reads and writes book_chapters directly; chapter titles editable inline; title-only changes correctly included in Save All",
"Grabber: storage toggle on the Convert page — choose between DB storage and EPUB file before converting",
],
},
],
},
{
"version": "v0.1.2",
"date": "2026-04-02",
"summary": "Restore functionality on the Backup page.",
"sections": [
{
"title": "New feature",
"type": "feature",
"changes": [
"Restore functionality on the Backup page: browse any available Dropbox snapshot, see which files are currently missing from disk, and restore individual books or a selection back to the library — file is written to disk and immediately re-indexed",
],
},
],
},
{
"version": "v0.1.1",
"date": "2026-03-31",
"summary": "Bug fixes, volume-aware duplicate detection, shared code cleanup, and a new Changelog page.",
"sections": [
{
"title": "Bug fixes",
"type": "bugfix",
"changes": [
"Duplicates view crashed on load due to a TypeError (g.books.length was undefined); counter was stale and the view never rendered",
"Duplicate detection was too aggressive: different volumes of the same series (same title + author, different volume) were incorrectly grouped as duplicates — now keyed on title + author + volume",
"Grabber preload: same volume-aware fix — only flags a duplicate when title, author, and volume all match; falls back to title + author when no volume is known",
"Bulk Import duplicate check: different volumes of the same series are no longer flagged as duplicates",
],
},
{
"title": "Improvements",
"type": "improvement",
"changes": [
"Search changed from search-as-you-type (250 ms debounce) to Enter-to-search — prevents the iPad keyboard from locking up on large collections",
"CBR reader: archive format now detected via magic bytes instead of file extension — .cbr files that are actually ZIP or 7-zip archives open correctly; added 7-zip support via py7zr",
"Docker: replaced unrar-free with proprietary unrar (RARLAB v6.2.6) — fixes failures on RAR archives using newer compression methods",
],
},
{
"title": "New feature",
"type": "feature",
"changes": [
"Changelog page (/changelog): structured release history with version, date, and categorised change lists",
],
},
{
"title": "Code quality",
"type": "improvement",
"changes": [
"Shared CSS (theme.css): single :root block with all global CSS custom properties; loaded on every page — no more duplicate inline :root blocks across templates",
"Shared JS (books.js): book helpers (bookTitle, bookAuthor, bookGenres, bookSubgenres, bookPlainTags, filterBooks) and search input wiring extracted into one shared file",
"Shared JS (conversion.js): SSE/EventSource logic (connectConversionStream, addLog) extracted from Convert and Grabber pages into one shared file",
],
},
],
},
{
"version": "v0.1.0",
"date": "2026-03-29",
"summary": "First release of Novela: a self-hosted personal library for EPUB, PDF, CBR, and CBZ files.",
"sections": [
{
"title": "Library",
"type": "feature",
"changes": [
"Grid and List view for all books and New books, with column visibility filter and persistent view mode",
"Sidebar navigation: All books, Want to Read, New, Incomplete, Series, Authors, Publishers, Archived, Bookmarks, Rated, Duplicates, Statistics — all with live counters",
"15 star ratings stored in the database and written back to EPUB OPF and CBZ ComicInfo.xml",
"Publication status: Complete, Ongoing, Temporary Hold, Long-Term Hold",
"Status and want-to-read badges on grid covers, always readable regardless of cover colour",
"Duplicate detection: groups books by title and author with a sidebar counter",
"Incomplete view: all non-archived books where publication status is not Complete",
"Rated view: non-archived books with a star rating, sorted by rating",
"Bulk delete in All books List view with multi-select and Shift+click range selection",
"Disk usage warning in sidebar (amber ≥ 85%, red ≥ 95% or low free space)",
"Autocomplete for Author, Publisher, and Series in the book edit panel",
"Series volume suffix support (e.g. 21a, 21b) and volume 0 for prequels and specials",
"Cover upload for EPUB books with cover cache for fast subsequent loads",
],
},
{
"title": "Reader",
"type": "feature",
"changes": [
"EPUB reader with chapter navigation, scroll progress, and bookmarks",
"PDF reader with page-image rendering and page navigation",
"CBR/CBZ reader with page-image rendering; format detection via magic bytes (supports ZIP, RAR, and 7-zip archives)",
"Reader text colour: 5 warm-tone presets, persisted per browser",
"Content width slider (30100 vw), persisted per browser",
"Bookmarks: save position with optional note; navigate back via sidebar or bookmark list",
],
},
{
"title": "Import & Convert",
"type": "feature",
"changes": [
"Single-file import: drag-and-drop or file picker for EPUB, PDF, CBR, CBZ",
"Bulk Import: batch import with %placeholder% filename pattern parsing, shared metadata, live preview table, and duplicate detection",
"Convert: scrape web fiction and convert to EPUB; warns if title and author already exist in the library",
"Grabber with credentials manager for site-specific login",
],
},
{
"title": "Book Builder",
"type": "feature",
"changes": [
"Create EPUB books from scratch via a WYSIWYG editor",
"Chapters with contenteditable editing; toolbar: bold, italic, underline, blockquote, author note, scene break, normalize",
"Autosave every 30 seconds and Ctrl+S; publish produces a standards-compliant EPUB 2.0 added directly to the library",
],
},
{
"title": "Following",
"type": "feature",
"changes": [
"Track external author URLs on the Following page",
"Two tabs: Following (authors with URL set) and All Authors",
"Inline URL editing with Enter/Escape support; Visit opens URL in a new tab",
"Sidebar counter shows number of followed authors",
],
},
{
"title": "Backup",
"type": "feature",
"changes": [
"Dropbox backup with versioned snapshots and object-store deduplication",
"OAuth2 refresh token flow (does not expire); legacy access token supported as fallback",
"Configurable backup root, snapshot retention, and scheduled interval",
"Live backup progress in sidebar (file count and phase); backup status indicator with time-ago",
"PostgreSQL dump included in each backup run",
],
},
{
"title": "Performance",
"type": "improvement",
"changes": [
"Library loads instantly for large collections: ETag 304 Not Modified, lazy cover loading via IntersectionObserver, single DOM pass rendering, SQL tag aggregation",
"Fast-path API (database-only); full disk rescan only on demand",
],
},
],
},
]

View File

@ -1,7 +1,9 @@
import io import io
import re import re
import zipfile import zipfile
from datetime import datetime, timezone
from html import escape as he from html import escape as he
from pathlib import Path
def detect_image_format(data: bytes, base: str) -> tuple[str, str]: def detect_image_format(data: bytes, base: str) -> tuple[str, str]:
@ -21,48 +23,125 @@ def detect_image_format(data: bytes, base: str) -> tuple[str, str]:
def add_cover_to_epub(epub_path, cover_data: bytes) -> None: def add_cover_to_epub(epub_path, cover_data: bytes) -> None:
"""Add a cover image to an existing EPUB and remove the Cover Missing tag.""" """Replace (or add) the cover image in an existing EPUB."""
cover_filename, cover_media_type = detect_image_format(cover_data, "cover") cover_filename, cover_media_type = detect_image_format(cover_data, "cover")
# Read existing zip into memory
with open(epub_path, "rb") as f: with open(epub_path, "rb") as f:
original = f.read() original = f.read()
with zipfile.ZipFile(io.BytesIO(original), "r") as zin:
names = zin.namelist()
# Locate the OPF via META-INF/container.xml
opf_path = "OEBPS/content.opf"
try:
container = zin.read("META-INF/container.xml").decode("utf-8", errors="replace")
m = re.search(r'full-path\s*=\s*["\']([^"\']+)["\']', container)
if m:
opf_path = m.group(1)
except Exception:
pass
opf_dir = opf_path.rsplit("/", 1)[0] if "/" in opf_path else ""
# Parse OPF to find the existing cover image path
old_cover_zip_path: str | None = None
try:
opf_text = zin.read(opf_path).decode("utf-8", errors="replace")
# Find item with id="cover*" that is an image
for m in re.finditer(
r'<item\b[^>]+id=["\']cover[^"\']*["\'][^>]*/?>',
opf_text,
):
href_m = re.search(r'href=["\']([^"\']+)["\']', m.group(0))
if href_m:
href = href_m.group(1)
zip_path = (opf_dir + "/" + href).lstrip("/") if opf_dir else href
# Normalise ../ segments
parts, resolved = zip_path.split("/"), []
for p in parts:
if p == ".." and resolved:
resolved.pop()
else:
resolved.append(p)
old_cover_zip_path = "/".join(resolved)
break
except Exception:
pass
# Decide where to write the new cover (same folder as old, or Images/ next to OPF)
if old_cover_zip_path:
cover_dir = old_cover_zip_path.rsplit("/", 1)[0] if "/" in old_cover_zip_path else ""
else:
cover_dir = (opf_dir + "/Images").lstrip("/") if opf_dir else "OEBPS/Images"
new_cover_zip_path = (cover_dir + "/" + cover_filename).lstrip("/")
# Rebuild the ZIP
buf = io.BytesIO() buf = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(original), "r") as zin, \ with zipfile.ZipFile(io.BytesIO(original), "r") as zin, \
zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zout: zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zout:
# Copy mimetype uncompressed first
info = zin.getinfo("mimetype")
zout.writestr(zipfile.ZipInfo("mimetype"), zin.read("mimetype"), compress_type=zipfile.ZIP_STORED) zout.writestr(zipfile.ZipInfo("mimetype"), zin.read("mimetype"), compress_type=zipfile.ZIP_STORED)
for item in zin.infolist(): for item in zin.infolist():
if item.filename == "mimetype": if item.filename == "mimetype":
continue continue
# Drop the old cover image (will be replaced below)
if old_cover_zip_path and item.filename == old_cover_zip_path:
continue
data = zin.read(item.filename) data = zin.read(item.filename)
if item.filename == opf_path:
if item.filename == "OEBPS/content.opf": data = _patch_opf(
data = _patch_opf(data.decode("utf-8"), cover_filename, cover_media_type).encode("utf-8") data.decode("utf-8"),
cover_filename,
cover_media_type,
old_cover_zip_path,
opf_dir,
).encode("utf-8")
zout.writestr(item, data) zout.writestr(item, data)
# Add the cover image # Write the new cover image
zout.writestr(f"OEBPS/Images/{cover_filename}", cover_data) zout.writestr(new_cover_zip_path, cover_data)
with open(epub_path, "wb") as f: with open(epub_path, "wb") as f:
f.write(buf.getvalue()) f.write(buf.getvalue())
def _patch_opf(opf: str, cover_filename: str, cover_media_type: str) -> str: def _patch_opf(
"""Insert cover into OPF manifest/metadata and remove Cover Missing dc:subject.""" opf: str,
cover_filename: str,
cover_media_type: str,
old_cover_zip_path: str | None,
opf_dir: str,
) -> str:
"""Replace or insert the cover manifest item and cover meta in an OPF."""
# Remove "Cover Missing" dc:subject # Remove "Cover Missing" dc:subject
opf = re.sub(r'\s*<dc:subject>Cover Missing</dc:subject>', '', opf) opf = re.sub(r'\s*<dc:subject>Cover Missing</dc:subject>', '', opf)
# Add cover manifest item before </manifest> # Remove existing cover manifest item(s) with id starting with "cover"
cover_item = f'<item id="cover-img" href="Images/{cover_filename}" media-type="{cover_media_type}"/>' opf = re.sub(r'\s*<item\b[^>]+id=["\']cover[^"\']*["\'][^>]*/>', '', opf)
opf = re.sub(r'\s*<item\b[^>]+id=["\']cover[^"\']*["\'][^>]*></item>', '', opf)
# Remove existing <meta name="cover" .../>
opf = re.sub(r'\s*<meta\b[^>]+name=["\']cover["\'][^>]*/>', '', opf)
# Compute relative href from OPF dir to the new cover
# new cover is placed in the same folder as the old one, relative to OPF
cover_href = cover_filename # same dir as OPF → just the filename
if old_cover_zip_path:
old_dir = old_cover_zip_path.rsplit("/", 1)[0] if "/" in old_cover_zip_path else ""
if old_dir != opf_dir:
# Make relative: e.g. opf_dir=EPUB, old_dir=EPUB/images → href=images/cover.jpg
if opf_dir and old_dir.startswith(opf_dir + "/"):
cover_href = old_dir[len(opf_dir) + 1:] + "/" + cover_filename
else:
cover_href = cover_filename
else:
cover_href = cover_filename
else:
cover_href = "Images/" + cover_filename
cover_item = f'<item id="cover-img" href="{cover_href}" media-type="{cover_media_type}"/>'
opf = opf.replace("</manifest>", f' {cover_item}\n </manifest>') opf = opf.replace("</manifest>", f' {cover_item}\n </manifest>')
# Add cover meta before </metadata>
cover_meta = '<meta name="cover" content="cover-img"/>' cover_meta = '<meta name="cover" content="cover-img"/>'
opf = opf.replace("</metadata>", f' {cover_meta}\n </metadata>') opf = opf.replace("</metadata>", f' {cover_meta}\n </metadata>')
@ -353,3 +432,147 @@ def write_epub_file(epub_path, internal_path: str, content: str) -> None:
with open(epub_path, "wb") as f: with open(epub_path, "wb") as f:
f.write(buf.getvalue()) f.write(buf.getvalue())
def build_epub(
title: str,
author: str,
publisher: str,
chapters: list[dict],
) -> bytes:
"""Bouw een EPUB 2.0 bestand vanuit builder-data. Geeft raw bytes terug."""
import uuid as _uuid
book_id = str(_uuid.uuid4())
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
# mimetype — ongecomprimeerd als eerste entry
mi = zipfile.ZipInfo("mimetype")
mi.compress_type = zipfile.ZIP_STORED
z.writestr(mi, "application/epub+zip")
z.writestr(
"META-INF/container.xml",
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n'
' <rootfiles>\n'
' <rootfile full-path="OEBPS/content.opf"'
' media-type="application/oebps-package+xml"/>\n'
' </rootfiles>\n'
'</container>\n',
)
style_css = (
"body { font-family: Georgia, serif; font-size: 1em;"
" line-height: 1.6; margin: 1em; }\n"
"p { margin: 0 0 0.8em 0; text-indent: 1.2em; }\n"
"p:first-child, h1 + p, h2 + p, h3 + p { text-indent: 0; }\n"
"h1, h2, h3 { font-weight: bold; margin: 1.2em 0 0.4em; }\n"
"blockquote { margin: 1em 2em; padding: 0.3em 0.8em;"
" border-left: 3px solid #aaa; }\n"
"blockquote.author-note { font-style: italic; color: #666;"
" border-left: 3px solid #555; margin: 1.2em 2em;"
" padding: 0.4em 1em; font-size: 0.92em; }\n"
"center img { display: block; margin: 1em auto; }\n"
)
z.writestr("OEBPS/Styles/style.css", style_css)
break_png_path = Path("static/break.png")
if break_png_path.exists():
z.write(str(break_png_path), "OEBPS/Images/break.png")
manifest_items: list[str] = []
spine_idrefs: list[str] = []
ncx_nav_points: list[str] = []
for i, ch in enumerate(chapters):
ch_id = f"chapter_{i + 1:03d}"
ch_filename = f"OEBPS/Text/{ch_id}.xhtml"
ch_title = he(ch.get("title") or f"Hoofdstuk {i + 1}")
ch_content = ch.get("content") or "<p></p>"
xhtml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"\n'
' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n'
'<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="nl">\n'
"<head>\n"
' <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>\n'
f" <title>{ch_title}</title>\n"
' <link rel="stylesheet" type="text/css" href="../Styles/style.css"/>\n'
"</head>\n"
"<body>\n"
f" <h2 class=\"chapter-title\">{ch_title}</h2>\n"
f" {ch_content}\n"
"</body>\n"
"</html>\n"
)
z.writestr(ch_filename, xhtml)
manifest_items.append(
f' <item id="{ch_id}" href="Text/{ch_id}.xhtml"'
f' media-type="application/xhtml+xml"/>'
)
spine_idrefs.append(f' <itemref idref="{ch_id}"/>')
ncx_nav_points.append(
f' <navPoint id="navPoint-{i + 1}" playOrder="{i + 2}">\n'
f' <navLabel><text>{ch_title}</text></navLabel>\n'
f' <content src="Text/{ch_id}.xhtml"/>\n'
f' </navPoint>'
)
safe_title = he(title)
safe_author = he(author)
ncx = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN"\n'
' "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">\n'
'<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">\n'
"<head>\n"
f' <meta name="dtb:uid" content="{book_id}"/>\n'
' <meta name="dtb:depth" content="1"/>\n'
' <meta name="dtb:totalPageCount" content="0"/>\n'
' <meta name="dtb:maxPageNumber" content="0"/>\n'
"</head>\n"
f"<docTitle><text>{safe_title}</text></docTitle>\n"
f"<docAuthor><text>{safe_author}</text></docAuthor>\n"
"<navMap>\n"
+ "\n".join(ncx_nav_points)
+ "\n</navMap>\n</ncx>\n"
)
z.writestr("OEBPS/toc.ncx", ncx)
has_break = break_png_path.exists()
opf = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<package xmlns="http://www.idpf.org/2007/opf" version="2.0"'
f' unique-identifier="BookId">\n'
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"'
' xmlns:opf="http://www.idpf.org/2007/opf">\n'
f' <dc:title>{safe_title}</dc:title>\n'
f' <dc:creator opf:role="aut">{safe_author}</dc:creator>\n'
f' <dc:publisher>{he(publisher or "")}</dc:publisher>\n'
f' <dc:identifier id="BookId" opf:scheme="UUID">{book_id}</dc:identifier>\n'
f' <dc:date>{now_str}</dc:date>\n'
' <dc:language>nl</dc:language>\n'
"</metadata>\n"
"<manifest>\n"
' <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>\n'
' <item id="style" href="Styles/style.css" media-type="text/css"/>\n'
+ (
' <item id="break-img" href="Images/break.png" media-type="image/png"/>\n'
if has_break else ""
)
+ "\n".join(manifest_items)
+ "\n</manifest>\n"
'<spine toc="ncx">\n'
+ "\n".join(spine_idrefs)
+ "\n</spine>\n"
"</package>\n"
)
z.writestr("OEBPS/content.opf", opf)
return buf.getvalue()

View File

@ -9,10 +9,15 @@ from migrations import run_migrations
from routers.backup import start_backup_scheduler, stop_backup_scheduler from routers.backup import start_backup_scheduler, stop_backup_scheduler
from routers import ( from routers import (
backup_router, backup_router,
builder_router,
bulk_import_router,
changelog_router,
editor_router, editor_router,
following_router,
grabber_router, grabber_router,
library_router, library_router,
reader_router, reader_router,
search_router,
settings_router, settings_router,
) )
@ -38,6 +43,11 @@ app.include_router(editor_router)
app.include_router(grabber_router) app.include_router(grabber_router)
app.include_router(settings_router) app.include_router(settings_router)
app.include_router(backup_router) app.include_router(backup_router)
app.include_router(builder_router)
app.include_router(bulk_import_router)
app.include_router(following_router)
app.include_router(changelog_router)
app.include_router(search_router)
@app.get("/") @app.get("/")

View File

@ -0,0 +1,259 @@
"""
One-time migration: move all library files to the correct path structure
and update all database references.
Target structure:
epub/{publisher}/{author}/Stories/{title}.epub
epub/{publisher}/{author}/Series/{series}/{idx:03d} - {title}.epub
pdf/{publisher}/{author}/{title}.pdf
comics/{publisher}/{author}/{title}.cbr|cbz
Run inside the novela container:
python migrate_paths.py [--execute]
Without --execute: dry-run only (no files moved, no DB changes).
"""
import os
import re
import sys
from pathlib import Path
import psycopg2
LIBRARY_DIR = Path("library")
LIBRARY_ROOT = LIBRARY_DIR.resolve()
DRY_RUN = "--execute" not in sys.argv
# ---------------------------------------------------------------------------
# Path helpers (mirrors common.py / reader.py logic)
# ---------------------------------------------------------------------------
def _clean(value: str, fallback: str, max_len: int) -> str:
txt = re.sub(r"\s+", " ", (value or "").strip())
txt = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", txt)
txt = re.sub(r"\.+$", "", txt).strip()
if not txt:
txt = fallback
return txt[:max_len]
def _coerce_index(value) -> int:
try:
return max(1, min(999, int(value or 1)))
except Exception:
return 1
def correct_rel_path(filename: str, title: str, author: str, publisher: str,
series: str, series_index: int) -> Path:
"""Compute the correct relative path for a book based on current metadata."""
ext = Path(filename).suffix.lower()
pub = _clean(publisher, "Unknown Publisher", 80)
auth = _clean(author, "Unknown Author", 80)
ttl = _clean(title or Path(filename).stem, "Untitled", 140)
if ext == ".epub":
series_name = _clean(series or "", "", 80)
if series_name:
idx = _coerce_index(series_index)
return Path("epub") / pub / auth / "Series" / series_name / f"{idx:03d} - {ttl}.epub"
return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub"
if ext == ".pdf":
return Path("pdf") / pub / auth / f"{ttl}.pdf"
# .cbr / .cbz
comics_ext = ext if ext in {".cbr", ".cbz"} else ".cbr"
return Path("comics") / pub / auth / f"{ttl}{comics_ext}"
def ensure_unique(rel_path: Path, exclude_current: Path) -> Path:
"""Add (2), (3), … suffix if target already exists (and isn't the current file)."""
candidate = rel_path
counter = 2
while True:
full = (LIBRARY_DIR / candidate).resolve()
if full == exclude_current.resolve():
return candidate
if not full.exists():
return candidate
candidate = rel_path.with_name(
f"{rel_path.stem} ({counter}){rel_path.suffix}"
)
counter += 1
def prune_empty_dirs(start: Path) -> None:
cur = start.resolve()
while cur != LIBRARY_ROOT:
try:
cur.rmdir()
except OSError:
return
cur = cur.parent
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
db_url = (
f"host=novela-db "
f"dbname={os.environ['POSTGRES_DB']} "
f"user={os.environ['POSTGRES_USER']} "
f"password={os.environ['POSTGRES_PASSWORD']}"
)
conn = psycopg2.connect(db_url)
with conn.cursor() as cur:
cur.execute("""
SELECT filename, title, author, publisher, series, series_index
FROM library
ORDER BY filename
""")
books = cur.fetchall()
print(f"Total books in DB: {len(books)}")
print(f"Mode: {'DRY RUN' if DRY_RUN else '*** EXECUTE ***'}")
print()
moves = []
skipped_missing = []
skipped_same = []
conflicts = []
for (filename, title, author, publisher, series, series_index) in books:
old_path = (LIBRARY_DIR / filename).resolve()
new_rel = correct_rel_path(filename, title or "", author or "",
publisher or "", series or "", series_index or 0)
new_rel = ensure_unique(new_rel, old_path)
new_path = (LIBRARY_DIR / new_rel).resolve()
if not old_path.exists():
skipped_missing.append(filename)
continue
if old_path == new_path:
skipped_same.append(filename)
continue
# Sanity: target already exists and is a different file
if new_path.exists() and new_path != old_path:
conflicts.append((filename, new_rel.as_posix()))
continue
moves.append((filename, old_path, new_rel.as_posix(), new_path))
# Report
print(f"Already correct: {len(skipped_same)}")
print(f"File missing: {len(skipped_missing)}")
print(f"Conflicts: {len(conflicts)}")
print(f"To move: {len(moves)}")
print()
if skipped_missing:
print("=== MISSING FILES (skipped) ===")
for f in skipped_missing:
print(f" {f}")
print()
if conflicts:
print("=== CONFLICTS (skipped) ===")
for old, new in conflicts:
print(f" {old}")
print(f"{new} (target exists!)")
print()
if not moves:
print("Nothing to do.")
conn.close()
return
print("=== MOVES ===")
for old_fn, old_path, new_fn, new_path in moves:
print(f" {old_fn}")
print(f"{new_fn}")
print()
if DRY_RUN:
print("Dry run complete. Run with --execute to apply changes.")
conn.close()
return
# Execute
print("Applying changes...")
moved = 0
errors = []
prunable = set()
for old_fn, old_path, new_fn, new_path in moves:
try:
# Move file
new_path.parent.mkdir(parents=True, exist_ok=True)
old_path.rename(new_path)
prunable.add(old_path.parent)
# Update DB in a transaction
with conn:
with conn.cursor() as cur:
# Copy library row with new filename
cur.execute("""
INSERT INTO library (
filename, title, author, publisher, has_cover, media_type,
series, series_index, publication_status, want_to_read,
source_url, archived, needs_review, updated_at,
publish_date, description, rating
)
SELECT %s, title, author, publisher, has_cover, media_type,
series, series_index, publication_status, want_to_read,
source_url, archived, needs_review, updated_at,
publish_date, description, rating
FROM library WHERE filename = %s
""", (new_fn, old_fn))
# Update child tables
for table in ("book_tags", "reading_progress",
"reading_sessions", "library_cover_cache"):
cur.execute(
f"UPDATE {table} SET filename = %s WHERE filename = %s",
(new_fn, old_fn)
)
# Delete old library row (cascade removes any remaining child rows)
cur.execute("DELETE FROM library WHERE filename = %s", (old_fn,))
moved += 1
print(f" [{moved}/{len(moves)}] {old_fn}{new_fn}")
except Exception as e:
errors.append((old_fn, str(e)))
# Try to move file back if DB failed
if new_path.exists() and not old_path.exists():
try:
old_path.parent.mkdir(parents=True, exist_ok=True)
new_path.rename(old_path)
except Exception:
pass
print(f" ERROR: {old_fn}: {e}")
# Prune empty directories
print("\nPruning empty directories...")
for d in prunable:
prune_empty_dirs(d)
print()
print(f"Done. Moved: {moved}, Errors: {len(errors)}, Skipped (conflict): {len(conflicts)}, Missing: {len(skipped_missing)}")
if errors:
print("\nErrors:")
for fn, err in errors:
print(f" {fn}: {err}")
conn.close()
if __name__ == "__main__":
main()

View File

@ -193,6 +193,31 @@ def migrate_create_backup_log() -> None:
) )
def migrate_add_rating() -> None:
_exec("ALTER TABLE library ADD COLUMN IF NOT EXISTS rating SMALLINT NOT NULL DEFAULT 0")
def migrate_create_bookmarks() -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS bookmarks (
id SERIAL PRIMARY KEY,
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
chapter_index INTEGER NOT NULL DEFAULT 0,
scroll_frac REAL NOT NULL DEFAULT 0,
chapter_title VARCHAR(500) NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
)
"""
)
_exec("CREATE INDEX IF NOT EXISTS idx_bookmarks_filename ON bookmarks (filename)")
def migrate_remove_cover_missing_tag() -> None:
_exec("DELETE FROM book_tags WHERE tag = 'Cover Missing' AND tag_type = 'tag'")
def migrate_create_perf_indexes() -> None: def migrate_create_perf_indexes() -> None:
# Match library list sorting and common filters. # Match library list sorting and common filters.
_exec( _exec(
@ -227,6 +252,103 @@ def migrate_create_perf_indexes() -> None:
) )
def migrate_series_suffix() -> None:
_exec(
"""
ALTER TABLE library
ADD COLUMN IF NOT EXISTS series_suffix VARCHAR(10) NOT NULL DEFAULT ''
"""
)
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 migrate_create_authors() -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS authors (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
url VARCHAR(1000),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""
)
def migrate_rename_hiatus() -> None:
_exec("UPDATE library SET publication_status = 'Long-Term Hold' WHERE publication_status = 'Hiatus'")
def migrate_add_storage_type() -> None:
_exec(
"ALTER TABLE library ADD COLUMN IF NOT EXISTS storage_type VARCHAR(10) NOT NULL DEFAULT 'file'"
)
def migrate_create_book_images() -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS book_images (
sha256 CHAR(64) PRIMARY KEY,
ext VARCHAR(10) NOT NULL,
media_type VARCHAR(100) NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0
)
"""
)
def migrate_create_book_chapters() -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS book_chapters (
id SERIAL PRIMARY KEY,
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
chapter_index INTEGER NOT NULL,
title VARCHAR(500) NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
content_tsv TSVECTOR,
UNIQUE (filename, chapter_index)
)
"""
)
_exec(
"CREATE INDEX IF NOT EXISTS idx_book_chapters_filename ON book_chapters (filename, chapter_index)"
)
_exec(
"CREATE INDEX IF NOT EXISTS idx_book_chapters_tsv ON book_chapters USING GIN (content_tsv)"
)
def migrate_rebuild_chapter_tsv_with_title() -> None:
"""Rebuild content_tsv to include chapter title (safe to run repeatedly)."""
_exec(
"""
UPDATE book_chapters
SET content_tsv = to_tsvector('simple',
COALESCE(title, '') || ' ' ||
regexp_replace(COALESCE(content, ''), '<[^>]*>', ' ', 'g'))
"""
)
def run_migrations() -> None: def run_migrations() -> None:
migrate_create_library() migrate_create_library()
migrate_create_book_tags() migrate_create_book_tags()
@ -238,3 +360,14 @@ def run_migrations() -> None:
migrate_create_backup_log() migrate_create_backup_log()
migrate_create_perf_indexes() migrate_create_perf_indexes()
migrate_seed_break_patterns() migrate_seed_break_patterns()
migrate_add_rating()
migrate_remove_cover_missing_tag()
migrate_create_bookmarks()
migrate_series_suffix()
migrate_create_builder_drafts()
migrate_create_authors()
migrate_rename_hiatus()
migrate_add_storage_type()
migrate_create_book_images()
migrate_create_book_chapters()
migrate_rebuild_chapter_tsv_with_title()

View File

@ -0,0 +1,256 @@
"""
One-time recovery: retrieve 049 - De Cock en het lijk op drift.epub from
Dropbox backup, place it at the correct library path, and re-insert the DB row.
Run inside the novela container:
python recover_decock049.py [--execute]
Without --execute: dry-run only (shows what would be restored).
"""
import json
import os
import sys
from pathlib import Path
import dropbox
import psycopg2
from security import decrypt_value
DRY_RUN = "--execute" not in sys.argv
LIBRARY_DIR = Path("library")
TARGET_REL = "epub/Unknown Publisher/A.C. Baantjer/Series/De Cock (Series)/049 - De Cock en het lijk op drift.epub"
SEARCH_KEYWORDS = ["de cock", "049", "lijk op drift"]
def _db_conn():
return psycopg2.connect(
f"host=novela-db "
f"dbname={os.environ['POSTGRES_DB']} "
f"user={os.environ['POSTGRES_USER']} "
f"password={os.environ['POSTGRES_PASSWORD']}"
)
def _load_dropbox_token(conn) -> str:
with conn.cursor() as cur:
cur.execute(
"SELECT username, password FROM credentials WHERE site = 'dropbox' LIMIT 1"
)
row = cur.fetchone()
if not row:
raise RuntimeError("No Dropbox token in credentials table.")
username_raw, password_raw = row
username = decrypt_value(username_raw)
password = decrypt_value(password_raw)
token = (password or username or "").strip()
if not token:
raise RuntimeError("Dropbox token is empty.")
return token
def _load_dropbox_root(conn) -> str:
with conn.cursor() as cur:
cur.execute(
"SELECT username, password FROM credentials WHERE site = 'dropbox_backup_root' LIMIT 1"
)
row = cur.fetchone()
if not row:
return "/novela"
_, password_raw = row
root = decrypt_value(password_raw).strip() or "/novela"
if not root.startswith("/"):
root = "/" + root
return root
def _dropbox_join(root: str, *parts: str) -> str:
segs = [p.strip("/") for p in parts if p and p.strip("/")]
base = root.rstrip("/")
return base + "/" + "/".join(segs) if segs else base
def _list_snapshots(client, snapshots_root: str) -> list[str]:
paths = []
try:
res = client.files_list_folder(snapshots_root, recursive=False)
except Exception as e:
raise RuntimeError(f"Cannot list snapshots folder '{snapshots_root}': {e}")
while True:
for entry in res.entries:
if isinstance(entry, dropbox.files.FileMetadata):
if entry.name.endswith(".json"):
paths.append(entry.path_display)
if not res.has_more:
break
res = client.files_list_folder_continue(res.cursor)
return sorted(paths, reverse=True) # newest first
def _load_snapshot(client, path: str) -> dict:
_meta, resp = client.files_download(path)
return json.loads(resp.content.decode("utf-8", errors="replace"))
def _find_file_in_snapshot(snap: dict) -> tuple[str, str] | None:
"""Return (rel_path, sha256) for De Cock 049, or None."""
files = snap.get("files", {})
for rel, info in files.items():
rel_lower = rel.lower()
if all(kw in rel_lower for kw in SEARCH_KEYWORDS):
sha256 = info.get("sha256", "")
return rel, sha256
return None
def _download_object(client, objects_root: str, sha256: str) -> bytes:
obj_path = _dropbox_join(objects_root, sha256[:2], sha256)
print(f" Downloading object: {obj_path}")
_meta, resp = client.files_download(obj_path)
return resp.content
def _insert_db_row(conn, filename: str, snap_entry: dict, orig_filename: str) -> None:
"""Copy library row from orig_filename if it exists, else insert minimal row."""
with conn.cursor() as cur:
# Check if orig row exists in DB
cur.execute("SELECT * FROM library WHERE filename = %s LIMIT 1", (orig_filename,))
orig = cur.fetchone()
if orig:
cols = [desc.name for desc in conn.cursor().description] if False else None
# Fetch column names separately
with conn.cursor() as cur2:
cur2.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name='library' ORDER BY ordinal_position"
)
cols = [r[0] for r in cur2.fetchall()]
with conn.cursor() as cur3:
cur3.execute(
f"SELECT {', '.join(cols)} FROM library WHERE filename = %s LIMIT 1",
(orig_filename,),
)
row = cur3.fetchone()
if row:
data = dict(zip(cols, row))
data["filename"] = filename
col_list = ", ".join(data.keys())
placeholders = ", ".join(["%s"] * len(data))
cur3.execute(
f"INSERT INTO library ({col_list}) VALUES ({placeholders}) "
f"ON CONFLICT (filename) DO NOTHING",
list(data.values()),
)
print(f" DB row copied from '{orig_filename}''{filename}'")
return
# No orig row: insert minimal
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO library (filename, title, author, publisher, series, series_index,
media_type, has_cover, needs_review)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (filename) DO NOTHING
""",
(
filename,
"De Cock en het lijk op drift",
"A.C. Baantjer",
"Unknown Publisher",
"De Cock (Series)",
49,
"epub",
False,
True,
),
)
print(f" DB row inserted (minimal) for '{filename}'")
def main():
print(f"Mode: {'DRY RUN' if DRY_RUN else '*** EXECUTE ***'}")
print()
conn = _db_conn()
token = _load_dropbox_token(conn)
dropbox_root = _load_dropbox_root(conn)
print(f"Dropbox root: {dropbox_root}")
client = dropbox.Dropbox(token, timeout=120)
try:
acct = client.users_get_current_account()
print(f"Dropbox account: {acct.email}")
except Exception as e:
print(f"ERROR: Dropbox auth failed: {e}")
conn.close()
return
objects_root = _dropbox_join(dropbox_root, "library_objects")
snapshots_root = _dropbox_join(dropbox_root, "library_snapshots")
print(f"\nListing snapshots in: {snapshots_root}")
snapshots = _list_snapshots(client, snapshots_root)
print(f"Found {len(snapshots)} snapshots.")
for s in snapshots:
print(f" {s}")
found_rel = None
found_sha256 = None
found_snapshot = None
for snap_path in snapshots:
print(f"\nSearching snapshot: {snap_path}")
snap = _load_snapshot(client, snap_path)
result = _find_file_in_snapshot(snap)
if result:
found_rel, found_sha256 = result
found_snapshot = snap_path
print(f" FOUND: {found_rel}")
print(f" sha256: {found_sha256}")
break
else:
print(" Not found in this snapshot.")
if not found_rel:
print("\nERROR: File not found in any snapshot. Cannot recover.")
conn.close()
return
target_path = LIBRARY_DIR / TARGET_REL
print(f"\nTarget path: {target_path}")
if target_path.exists():
print("File already exists at target path. Nothing to do.")
conn.close()
return
if DRY_RUN:
print(f"\nDry run: would download sha256={found_sha256}")
print(f" and write to: {target_path}")
print("\nRun with --execute to apply.")
conn.close()
return
# Download
data = _download_object(client, objects_root, found_sha256)
print(f" Downloaded {len(data):,} bytes.")
# Write file
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(data)
print(f" Written to: {target_path}")
# DB
with conn:
_insert_db_row(conn, TARGET_REL, {}, found_rel)
print(f"\nDone. File recovered to: {target_path}")
conn.close()
if __name__ == "__main__":
main()

View File

@ -9,6 +9,7 @@ jinja2==3.1.4
Pillow==11.0.0 Pillow==11.0.0
pymupdf==1.24.0 pymupdf==1.24.0
rarfile==4.2 rarfile==4.2
py7zr==0.22.0
dropbox==12.0.2 dropbox==12.0.2
apscheduler==3.10.4 apscheduler==3.10.4
cryptography==44.0.1 cryptography==44.0.1

View File

@ -1,8 +1,13 @@
from routers.backup import router as backup_router from routers.backup import router as backup_router
from routers.builder import router as builder_router
from routers.bulk_import import router as bulk_import_router
from routers.changelog import router as changelog_router
from routers.editor import router as editor_router from routers.editor import router as editor_router
from routers.following import router as following_router
from routers.grabber import router as grabber_router from routers.grabber import router as grabber_router
from routers.library import router as library_router from routers.library import router as library_router
from routers.reader import router as reader_router from routers.reader import router as reader_router
from routers.search import router as search_router
from routers.settings import router as settings_router from routers.settings import router as settings_router
__all__ = [ __all__ = [
@ -12,4 +17,9 @@ __all__ = [
"grabber_router", "grabber_router",
"backup_router", "backup_router",
"settings_router", "settings_router",
"builder_router",
"bulk_import_router",
"following_router",
"changelog_router",
"search_router",
] ]

View File

@ -7,14 +7,17 @@ import subprocess
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from urllib.parse import urlencode
import dropbox import dropbox
import httpx
from dropbox.exceptions import ApiError, AuthError from dropbox.exceptions import ApiError, AuthError
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from db import get_db_conn from db import get_db_conn
from routers.common import scan_media, upsert_book
from security import decrypt_value, encrypt_value, is_encrypted_value from security import decrypt_value, encrypt_value, is_encrypted_value
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@ -31,6 +34,7 @@ DEFAULT_SCHEDULE_INTERVAL_HOURS = 24
BACKUP_TASKS: dict[int, asyncio.Task] = {} BACKUP_TASKS: dict[int, asyncio.Task] = {}
BACKUP_PROGRESS: dict[int, dict] = {} # log_id → {done, total, phase}
SCHEDULER_TASK: asyncio.Task | None = None SCHEDULER_TASK: asyncio.Task | None = None
@ -95,6 +99,66 @@ def _load_dropbox_token() -> str:
return _dropbox_credential_details().get("token", "") return _dropbox_credential_details().get("token", "")
def _load_dropbox_app_key() -> str:
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
"SELECT password FROM credentials WHERE site = 'dropbox_app_key' LIMIT 1"
)
row = cur.fetchone()
if not row:
return ""
return decrypt_value(row[0]).strip()
def _load_dropbox_app_secret() -> str:
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
"SELECT password FROM credentials WHERE site = 'dropbox_app_secret' LIMIT 1"
)
row = cur.fetchone()
if not row:
return ""
return decrypt_value(row[0]).strip()
def _save_dropbox_app_key(app_key: str) -> None:
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO credentials (site, username, password, updated_at)
VALUES ('dropbox_app_key', %s, %s, NOW())
ON CONFLICT (site) DO UPDATE
SET username = EXCLUDED.username,
password = EXCLUDED.password,
updated_at = NOW()
""",
(encrypt_value(""), encrypt_value(app_key.strip())),
)
def _save_dropbox_app_secret(app_secret: str) -> None:
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO credentials (site, username, password, updated_at)
VALUES ('dropbox_app_secret', %s, %s, NOW())
ON CONFLICT (site) DO UPDATE
SET username = EXCLUDED.username,
password = EXCLUDED.password,
updated_at = NOW()
""",
(encrypt_value(""), encrypt_value(app_secret.strip())),
)
def _normalize_dropbox_root(value: str | None) -> str: def _normalize_dropbox_root(value: str | None) -> str:
root = (value or "").strip() or DEFAULT_DROPBOX_ROOT root = (value or "").strip() or DEFAULT_DROPBOX_ROOT
if not root.startswith("/"): if not root.startswith("/"):
@ -325,14 +389,36 @@ def _save_dropbox_retention_count(retention_count: int) -> None:
def _dbx() -> dropbox.Dropbox: def _dbx() -> dropbox.Dropbox:
"""
Maak een Dropbox client aan.
Voorkeursvolgorde:
1. App key + app secret + refresh token -> automatische token refresh
2. Legacy access token (achterwaartse compatibiliteit)
"""
token = _load_dropbox_token() token = _load_dropbox_token()
if not token: if not token:
raise RuntimeError("Dropbox token not found in credentials (site='dropbox').") raise RuntimeError("Dropbox token not found in credentials (site='dropbox').")
client = dropbox.Dropbox(token, timeout=120)
app_key = _load_dropbox_app_key()
app_secret = _load_dropbox_app_secret()
try: try:
if app_key and app_secret:
client = dropbox.Dropbox(
oauth2_refresh_token=token,
app_key=app_key,
app_secret=app_secret,
timeout=120,
)
else:
# Fallback: legacy access token
client = dropbox.Dropbox(token, timeout=120)
client.users_get_current_account() client.users_get_current_account()
except AuthError as e: except AuthError as e:
raise RuntimeError(f"Dropbox auth failed: {e}") raise RuntimeError(f"Dropbox auth failed: {e}")
return client return client
@ -586,10 +672,17 @@ def _prune_orphan_objects(client: dropbox.Dropbox, objects_root: str, referenced
return _dropbox_delete_paths(client, to_delete) return _dropbox_delete_paths(client, to_delete)
def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]: def _run_backup_internal(*, dry_run: bool, progress_key: int | None = None) -> tuple[int, int]:
def _prog(done: int, total: int, phase: str) -> None:
if progress_key is not None:
BACKUP_PROGRESS[progress_key] = {"done": done, "total": total, "phase": phase}
client = None if dry_run else _dbx() client = None if dry_run else _dbx()
manifest = _load_manifest() manifest = _load_manifest()
files = _iter_library_files() files = _iter_library_files()
total_files = len(files)
_prog(0, total_files, "scanning")
uploaded_count = 0 uploaded_count = 0
uploaded_size = 0 uploaded_size = 0
@ -607,7 +700,8 @@ def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]:
snapshot_files: dict[str, dict[str, float | int | str]] = {} snapshot_files: dict[str, dict[str, float | int | str]] = {}
for path in files: for idx, path in enumerate(files):
_prog(idx, total_files, "uploading")
rel = path.relative_to(LIBRARY_DIR).as_posix() rel = path.relative_to(LIBRARY_DIR).as_posix()
state = _current_file_state(path) state = _current_file_state(path)
prev = manifest.get(rel, {}) if isinstance(manifest.get(rel), dict) else {} prev = manifest.get(rel, {}) if isinstance(manifest.get(rel), dict) else {}
@ -639,6 +733,8 @@ def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]:
uploaded_size += int(state["size"]) uploaded_size += int(state["size"])
uploaded_count += 1 uploaded_count += 1
_prog(total_files, total_files, "snapshot")
snapshot = { snapshot = {
"created_at": _now_iso(), "created_at": _now_iso(),
"retention_count": retention_count, "retention_count": retention_count,
@ -661,6 +757,8 @@ def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]:
uploaded_size += len(snapshot_data) uploaded_size += len(snapshot_data)
uploaded_count += 1 uploaded_count += 1
_prog(total_files, total_files, "pg_dump")
dump_data, dump_name = _run_pg_dump() dump_data, dump_name = _run_pg_dump()
dump_target = _dropbox_join(dropbox_root, "postgres", dump_name) dump_target = _dropbox_join(dropbox_root, "postgres", dump_name)
if client is not None: if client is not None:
@ -691,10 +789,15 @@ async def backup_dropbox_credentials():
preview = "" preview = ""
if token: if token:
preview = f"{token[:4]}...{token[-4:]}" if len(token) >= 10 else "(configured)" preview = f"{token[:4]}...{token[-4:]}" if len(token) >= 10 else "(configured)"
app_key = _load_dropbox_app_key()
app_secret = _load_dropbox_app_secret()
return { return {
"configured": bool(token), "configured": bool(token),
"token_preview": preview, "token_preview": preview,
"updated_at": details.get("updated_at"), "updated_at": details.get("updated_at"),
"app_key_configured": bool(app_key and app_secret),
"dropbox_root": root_details.get("root", DEFAULT_DROPBOX_ROOT), "dropbox_root": root_details.get("root", DEFAULT_DROPBOX_ROOT),
"root_updated_at": root_details.get("updated_at"), "root_updated_at": root_details.get("updated_at"),
"retention_count": int(retention_details.get("retention_count", DEFAULT_RETENTION_COUNT)), "retention_count": int(retention_details.get("retention_count", DEFAULT_RETENTION_COUNT)),
@ -719,6 +822,9 @@ async def backup_dropbox_credentials_save(request: Request):
if not token: if not token:
return {"ok": False, "error": "Dropbox token is required."} return {"ok": False, "error": "Dropbox token is required."}
app_key = (body.get("app_key") or "").strip()
app_secret = (body.get("app_secret") or "").strip()
dropbox_root = _normalize_dropbox_root(body.get("dropbox_root") or _load_dropbox_root()) dropbox_root = _normalize_dropbox_root(body.get("dropbox_root") or _load_dropbox_root())
raw_retention = body.get("retention_count", _load_dropbox_retention_count()) raw_retention = body.get("retention_count", _load_dropbox_retention_count())
try: try:
@ -748,6 +854,11 @@ async def backup_dropbox_credentials_save(request: Request):
(encrypt_value(""), encrypt_value(token)), (encrypt_value(""), encrypt_value(token)),
) )
if app_key:
_save_dropbox_app_key(app_key)
if app_secret:
_save_dropbox_app_secret(app_secret)
_save_dropbox_root(dropbox_root) _save_dropbox_root(dropbox_root)
_save_dropbox_retention_count(retention_count) _save_dropbox_retention_count(retention_count)
_save_backup_schedule(schedule_enabled, schedule_interval_hours) _save_backup_schedule(schedule_enabled, schedule_interval_hours)
@ -768,7 +879,14 @@ async def backup_dropbox_credentials_delete():
with conn: with conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
"DELETE FROM credentials WHERE site IN ('dropbox', 'dropbox_backup_root', 'dropbox_backup_retention', 'dropbox_backup_schedule')" """DELETE FROM credentials WHERE site IN (
'dropbox',
'dropbox_app_key',
'dropbox_app_secret',
'dropbox_backup_root',
'dropbox_backup_retention',
'dropbox_backup_schedule'
)"""
) )
return {"ok": True} return {"ok": True}
@ -797,6 +915,8 @@ async def backup_health():
"dropbox_error": dropbox_error, "dropbox_error": dropbox_error,
"dropbox_root": dropbox_root, "dropbox_root": dropbox_root,
"retention_count": retention_count, "retention_count": retention_count,
"schedule_enabled": schedule_enabled,
"schedule_interval_hours": schedule_interval_hours,
"pg_dump_available": bool(pg_dump_path), "pg_dump_available": bool(pg_dump_path),
"pg_dump_path": pg_dump_path, "pg_dump_path": pg_dump_path,
"library_exists": LIBRARY_DIR.exists(), "library_exists": LIBRARY_DIR.exists(),
@ -917,8 +1037,11 @@ async def stop_backup_scheduler() -> None:
async def _run_backup_job(log_id: int, dry_run: bool) -> None: async def _run_backup_job(log_id: int, dry_run: bool) -> None:
BACKUP_PROGRESS[log_id] = {"done": 0, "total": 0, "phase": "starting"}
try: try:
files_count, size_bytes = await asyncio.to_thread(_run_backup_internal, dry_run=dry_run) files_count, size_bytes = await asyncio.to_thread(
_run_backup_internal, dry_run=dry_run, progress_key=log_id
)
_finish_backup_log( _finish_backup_log(
log_id, log_id,
status="success", status="success",
@ -936,6 +1059,115 @@ async def _run_backup_job(log_id: int, dry_run: bool) -> None:
) )
finally: finally:
BACKUP_TASKS.pop(log_id, None) BACKUP_TASKS.pop(log_id, None)
BACKUP_PROGRESS.pop(log_id, None)
@router.post("/api/backup/oauth/prepare")
async def oauth_prepare(request: Request):
"""
Sla app key + secret op en geef de Dropbox autorisatie-URL terug.
De gebruiker opent deze URL in de browser en krijgt een code te zien.
Gebruikt token_access_type=offline voor een refresh token dat niet verloopt.
"""
body = {}
try:
body = await request.json()
except Exception:
pass
app_key = (body.get("app_key") or "").strip()
app_secret = (body.get("app_secret") or "").strip()
if not app_key or not app_secret:
return {"ok": False, "error": "app_key and app_secret are required."}
_save_dropbox_app_key(app_key)
_save_dropbox_app_secret(app_secret)
params = urlencode({
"client_id": app_key,
"response_type": "code",
"token_access_type": "offline",
})
auth_url = f"https://www.dropbox.com/oauth2/authorize?{params}"
return {"ok": True, "auth_url": auth_url}
@router.post("/api/backup/oauth/exchange")
async def oauth_exchange(request: Request):
"""
Wissel de door de gebruiker ingevoerde autorisatiecode in voor een refresh token.
Slaat het refresh token op als het Dropbox-token.
"""
body = {}
try:
body = await request.json()
except Exception:
pass
code = (body.get("code") or "").strip()
if not code:
return {"ok": False, "error": "Authorization code is required."}
app_key = _load_dropbox_app_key()
app_secret = _load_dropbox_app_secret()
if not app_key or not app_secret:
return {"ok": False, "error": "App key and secret not found. Run prepare step first."}
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
"https://api.dropbox.com/oauth2/token",
data={
"code": code,
"grant_type": "authorization_code",
},
auth=(app_key, app_secret),
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as e:
return {"ok": False, "error": f"Dropbox API error: {e.response.status_code} {e.response.text[:200]}"}
except Exception as e:
return {"ok": False, "error": str(e)}
refresh_token = data.get("refresh_token", "").strip()
if not refresh_token:
return {"ok": False, "error": "No refresh token in Dropbox response. Make sure token_access_type=offline was used."}
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO credentials (site, username, password, updated_at)
VALUES ('dropbox', %s, %s, NOW())
ON CONFLICT (site) DO UPDATE
SET username = EXCLUDED.username,
password = EXCLUDED.password,
updated_at = NOW()
""",
(encrypt_value(""), encrypt_value(refresh_token)),
)
return {"ok": True, "message": "Refresh token saved. Dropbox is now connected."}
@router.get("/api/backup/progress")
async def backup_progress():
if not BACKUP_PROGRESS:
return {"running": False}
log_id = max(BACKUP_PROGRESS.keys())
p = BACKUP_PROGRESS[log_id]
return {
"running": True,
"log_id": log_id,
"done": p.get("done", 0),
"total": p.get("total", 0),
"phase": p.get("phase", ""),
}
@router.post("/api/backup/run") @router.post("/api/backup/run")
@ -965,3 +1197,131 @@ async def run_backup(request: Request):
"message": "Backup started in background.", "message": "Backup started in background.",
"started_at": _now_iso(), "started_at": _now_iso(),
} }
def _parse_snapshot_date(name: str) -> str:
"""Parse 'snapshot-20260329-123456.json''2026-03-29T12:34:56Z'."""
stem = Path(name).stem # snapshot-20260329-123456
parts = stem.split("-")
if len(parts) >= 3:
d, t = parts[1], parts[2]
if len(d) == 8 and len(t) == 6:
return f"{d[:4]}-{d[4:6]}-{d[6:]}T{t[:2]}:{t[2:4]}:{t[4:]}Z"
return ""
def _download_and_restore(client: dropbox.Dropbox, objects_root: str, rel: str, info: dict) -> None:
sha256 = str(info.get("sha256") or "")
if not sha256:
raise ValueError("No sha256 in snapshot entry")
obj_path = _object_path(objects_root, sha256)
_meta, res = client.files_download(obj_path)
data = res.content
dest = LIBRARY_DIR / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
meta = scan_media(dest)
tags = [(s, "subject") for s in meta.get("subjects", [])]
with get_db_conn() as conn:
with conn:
upsert_book(conn, rel, meta, tags)
@router.get("/api/backup/snapshots")
async def list_snapshots():
try:
client = await asyncio.to_thread(_dbx)
except Exception as e:
return {"ok": False, "error": str(e), "snapshots": []}
dropbox_root = _load_dropbox_root()
snapshots_root = _dropbox_join(dropbox_root, "library_snapshots")
try:
paths = await asyncio.to_thread(_list_snapshot_paths, client, snapshots_root)
except Exception as e:
return {"ok": False, "error": str(e), "snapshots": []}
snapshots = [
{"name": Path(p).name, "created_at": _parse_snapshot_date(Path(p).name)}
for p in paths
]
return {"ok": True, "snapshots": snapshots}
@router.get("/api/backup/snapshots/{snapshot_name}/files")
async def snapshot_files(snapshot_name: str):
try:
client = await asyncio.to_thread(_dbx)
except Exception as e:
return {"ok": False, "error": str(e), "files": []}
dropbox_root = _load_dropbox_root()
snapshots_root = _dropbox_join(dropbox_root, "library_snapshots")
snapshot_path = _dropbox_join(snapshots_root, snapshot_name)
try:
snap = await asyncio.to_thread(_load_snapshot_data, client, snapshot_path)
except Exception as e:
return {"ok": False, "error": str(e), "files": []}
files_data = snap.get("files", {})
result = [
{
"path": rel,
"size": info.get("size", 0),
"sha256": info.get("sha256", ""),
"exists_locally": (LIBRARY_DIR / rel).exists(),
}
for rel, info in sorted(files_data.items())
if isinstance(info, dict)
]
return {"ok": True, "snapshot": snapshot_name, "files": result}
@router.post("/api/backup/restore")
async def restore_files(request: Request):
body = {}
try:
body = await request.json()
except Exception:
pass
snapshot_name = (body.get("snapshot_name") or "").strip()
files_to_restore: list[str] = body.get("files", [])
if not snapshot_name:
return {"ok": False, "error": "snapshot_name is required"}
if not files_to_restore:
return {"ok": False, "error": "No files specified"}
try:
client = await asyncio.to_thread(_dbx)
except Exception as e:
return {"ok": False, "error": str(e)}
dropbox_root = _load_dropbox_root()
snapshots_root = _dropbox_join(dropbox_root, "library_snapshots")
objects_root = _dropbox_join(dropbox_root, "library_objects")
snapshot_path = _dropbox_join(snapshots_root, snapshot_name)
try:
snap = await asyncio.to_thread(_load_snapshot_data, client, snapshot_path)
except Exception as e:
return {"ok": False, "error": f"Failed to load snapshot: {e}"}
files_data = snap.get("files", {})
results = []
for rel in files_to_restore:
if rel not in files_data:
results.append({"path": rel, "ok": False, "error": "Not found in snapshot"})
continue
try:
await asyncio.to_thread(_download_and_restore, client, objects_root, rel, files_data[rel])
results.append({"path": rel, "ok": True})
except Exception as e:
results.append({"path": rel, "ok": False, "error": str(e)})
ok_count = sum(1 for r in results if r["ok"])
return {"ok": True, "restored": ok_count, "total": len(results), "results": results}

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,145 @@
import json
import uuid
from pathlib import Path
from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from cbr import cbr_page_count
from db import get_db_conn
from routers.common import (
LIBRARY_DIR,
ensure_cover_cache_for_book,
ensure_unique_rel_path,
make_rel_path,
media_type_from_suffix,
parse_volume_str,
upsert_book,
)
templates = Jinja2Templates(directory="templates")
router = APIRouter()
@router.get("/bulk-import", response_class=HTMLResponse)
async def bulk_import_page(request: Request):
return templates.TemplateResponse(request, "bulk_import.html", {"active": "bulk_import"})
@router.post("/library/bulk-import")
async def library_bulk_import(
files: list[UploadFile] = File(...),
rows: str = Form(...),
shared: str = Form("{}"),
):
try:
rows_data = json.loads(rows)
shared_data = json.loads(shared)
except Exception:
return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400)
rows_by_name = {r["original_filename"]: r for r in rows_data}
shared_author = shared_data.get("author", "")
shared_publisher = shared_data.get("publisher", "")
shared_status = shared_data.get("status", "")
shared_genres = [t.strip() for t in shared_data.get("genres", "").split(",") if t.strip()]
shared_tags = [t.strip() for t in shared_data.get("tags", "").split(",") if t.strip()]
imported: list[str] = []
skipped: list[dict] = []
for upload in files:
try:
name = upload.filename or "upload.bin"
suffix = Path(name).suffix.lower()
if suffix not in {".epub", ".pdf", ".cbr", ".cbz"}:
skipped.append({"file": name, "reason": "Unsupported file type"})
continue
mt = media_type_from_suffix(Path(name))
if not mt:
skipped.append({"file": name, "reason": "Could not detect media type"})
continue
data = await upload.read()
if not data:
skipped.append({"file": name, "reason": "Empty upload"})
continue
row = rows_by_name.get(name, {})
title = (row.get("title") or "").strip() or Path(name).stem
author = (row.get("author") or "").strip() or shared_author
publisher = (row.get("publisher") or "").strip() or shared_publisher
series = (row.get("series") or "").strip() or shared_data.get("series", "")
series_index, series_suffix = parse_volume_str(row.get("volume") or "")
status = (row.get("status") or "").strip() or shared_status
year = str(row.get("year") or "").strip()
publish_date: str | None = f"{year}-01-01" if year.isdigit() and len(year) == 4 else None
row_genres = [t.strip() for t in (row.get("genres") or "").split(",") if t.strip()]
row_tags = [t.strip() for t in (row.get("tags") or "").split(",") if t.strip()]
genres = row_genres if row_genres else shared_genres
plain_tags = row_tags if row_tags else shared_tags
tmp = LIBRARY_DIR / f".import-{uuid.uuid4().hex}{suffix}"
tmp.parent.mkdir(parents=True, exist_ok=True)
tmp.write_bytes(data)
rel = ensure_unique_rel_path(
make_rel_path(
media_type=mt,
publisher=publisher,
author=author,
title=title,
series=series,
series_index=series_index,
series_suffix=series_suffix,
ext=suffix,
)
)
dest = LIBRARY_DIR / rel
dest.parent.mkdir(parents=True, exist_ok=True)
tmp.replace(dest)
rel_name = rel.as_posix()
has_cover = False
try:
if mt == "cbr":
has_cover = cbr_page_count(dest) > 0
except Exception:
pass
meta = {
"media_type": mt,
"title": title,
"author": author,
"publisher": publisher,
"series": series,
"series_index": series_index,
"series_suffix": series_suffix,
"publication_status": status,
"publish_date": publish_date,
"has_cover": has_cover,
"needs_review": True,
"description": "",
"source_url": "",
}
tag_tuples = [(g, "genre") for g in genres] + [(t, "tag") for t in plain_tags]
with get_db_conn() as conn:
with conn:
upsert_book(conn, rel_name, meta, tag_tuples)
ensure_cover_cache_for_book(conn, rel_name, dest, mt)
imported.append(rel_name)
except Exception as e:
skipped.append({"file": upload.filename or "upload", "reason": str(e)})
finally:
await upload.close()
return {"ok": True, "imported": imported, "skipped": skipped}

View File

@ -0,0 +1,17 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from changelog import CHANGELOG
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/changelog", response_class=HTMLResponse)
async def changelog_page(request: Request):
return templates.TemplateResponse("changelog.html", {
"request": request,
"active": "changelog",
"changelog": CHANGELOG,
})

View File

@ -1,4 +1,5 @@
import base64 import base64
import hashlib
import html as _html import html as _html
import io import io
import posixpath import posixpath
@ -18,10 +19,16 @@ from pdf import pdf_cover_thumb, pdf_page_count, pdf_scan_metadata
LIBRARY_DIR = Path("library") LIBRARY_DIR = Path("library")
LIBRARY_DIR.mkdir(exist_ok=True) LIBRARY_DIR.mkdir(exist_ok=True)
LIBRARY_ROOT = LIBRARY_DIR.resolve() LIBRARY_ROOT = LIBRARY_DIR.resolve()
IMAGES_DIR = LIBRARY_DIR / "images"
COVER_W = 300 COVER_W = 300
COVER_H = 450 COVER_H = 450
def is_db_filename(filename: str) -> bool:
"""True if the filename is a synthetic DB-stored book path (no file on disk)."""
return (filename or "").startswith("db/")
def clean_segment(value: str, fallback: str, max_len: int) -> str: def clean_segment(value: str, fallback: str, max_len: int) -> str:
txt = re.sub(r"\s+", " ", (value or "").strip()) txt = re.sub(r"\s+", " ", (value or "").strip())
txt = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", txt) txt = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", txt)
@ -52,31 +59,71 @@ def media_type_from_suffix(path: Path) -> str:
return "" return ""
def parse_volume_str(value: int | str | None) -> tuple[int, str]:
"""Parse a volume string like '21a' or '0' into (index, suffix).
Returns (0, '') for anything unparseable.
index is clamped to 0999; suffix is lowercased alpha only, max 5 chars.
"""
s = str(value or "").strip()
m = re.match(r"^(\d+)([a-zA-Z]*)$", s)
if m:
idx = max(0, min(999, int(m.group(1))))
suffix = m.group(2).lower()[:5]
return idx, suffix
try:
return max(0, min(999, int(float(s)))), ""
except Exception:
return 0, ""
def coerce_series_index(value: int | str | None) -> int: def coerce_series_index(value: int | str | None) -> int:
try: try:
return max(1, min(999, int(value or 1))) return max(0, min(999, int(value or 0)))
except Exception: except Exception:
return 1 return 0
def make_rel_path(*, media_type: str, publisher: str, author: str, title: str, series: str, series_index: int | str | None) -> Path: def make_rel_path(*, media_type: str, publisher: str, author: str, title: str, series: str, series_index: int | str | None, series_suffix: str = "", ext: str = "") -> Path:
if media_type == "db":
pub = clean_segment(publisher, "Unknown Publisher", 80)
auth = clean_segment(author, "Unknown Author", 80)
ttl = clean_segment(title, "Untitled", 140)
series_name = clean_segment(series, "", 80)
if series_name:
idx = coerce_series_index(series_index)
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5]
return Path("db") / pub / auth / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}"
return Path("db") / pub / auth / ttl
if media_type == "epub": if media_type == "epub":
pub = clean_segment(publisher, "Unknown Publisher", 80) pub = clean_segment(publisher, "Unknown Publisher", 80)
auth = clean_segment(author, "Unknown Author", 80) auth = clean_segment(author, "Unknown Author", 80)
ttl = clean_segment(title, "Untitled", 140) ttl = clean_segment(title, "Untitled", 140)
series_name = clean_segment(series, "", 80) series_name = clean_segment(series, "", 80)
if series_name: if series_name:
return Path("epub") / pub / auth / "Series" / series_name / f"{coerce_series_index(series_index):03d} - {ttl}.epub" idx = coerce_series_index(series_index)
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5]
return Path("epub") / pub / auth / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}.epub"
return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub" return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub"
if media_type == "pdf": if media_type == "pdf":
pub = clean_segment(publisher, "Unknown Publisher", 80)
auth = clean_segment(author, "Unknown Author", 80) auth = clean_segment(author, "Unknown Author", 80)
ttl = clean_segment(title, "Untitled", 140) ttl = clean_segment(title, "Untitled", 140)
return Path("pdf") / auth / f"{ttl}.pdf" return Path("pdf") / pub / auth / f"{ttl}.pdf"
# CBR / CBZ — preserve the original extension; default to .cbr
comics_ext = ext if ext in {".cbr", ".cbz"} else ".cbr"
pub = clean_segment(publisher, "Unknown Publisher", 80)
auth = clean_segment(author, "Unknown", 80) auth = clean_segment(author, "Unknown", 80)
ttl = clean_segment(title, "Untitled", 140) ttl = clean_segment(title, "Untitled", 140)
return Path("comics") / auth / f"{ttl}.cbr" series_name = clean_segment(series, "", 80)
if series_name:
idx = coerce_series_index(series_index)
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5]
return Path("comics") / pub / auth / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}{comics_ext}"
return Path("comics") / pub / auth / f"{ttl}{comics_ext}"
def ensure_unique_rel_path(rel_path: Path) -> Path: def ensure_unique_rel_path(rel_path: Path) -> Path:
@ -191,6 +238,7 @@ def scan_epub(path: Path) -> dict:
"has_cover": False, "has_cover": False,
"series": "", "series": "",
"series_index": 0, "series_index": 0,
"series_suffix": "",
"title": "", "title": "",
"publication_status": "", "publication_status": "",
"author": "", "author": "",
@ -229,6 +277,9 @@ def scan_epub(path: Path) -> dict:
out["series_index"] = int(float(m.group(1))) out["series_index"] = int(float(m.group(1)))
except Exception: except Exception:
out["series_index"] = 0 out["series_index"] = 0
m = re.search(r'<meta[^>]*name="novela:series_suffix"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
if m:
out["series_suffix"] = re.sub(r"[^a-z]", "", m.group(1).lower())[:5]
m = re.search(r'<meta[^>]*name="publication_status"[^>]*content="([^"]+)"', opf, re.IGNORECASE) m = re.search(r'<meta[^>]*name="publication_status"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
if m: if m:
out["publication_status"] = _html.unescape(m.group(1).strip()) out["publication_status"] = _html.unescape(m.group(1).strip())
@ -246,11 +297,34 @@ def scan_epub(path: Path) -> dict:
for s in re.findall(r"<(?:dc:)?subject[^>]*>(.*?)</(?:dc:)?subject>", opf, re.DOTALL | re.IGNORECASE) for s in re.findall(r"<(?:dc:)?subject[^>]*>(.*?)</(?:dc:)?subject>", opf, re.DOTALL | re.IGNORECASE)
if s.strip() if s.strip()
] ]
m = re.search(r'<meta[^>]*name="novela:rating"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
if m:
try:
out["rating"] = max(0, min(5, int(m.group(1))))
except Exception:
pass
except Exception: except Exception:
pass pass
return out return out
def scan_cbz_rating(path: Path) -> int:
"""Read NovelaRating from ComicInfo.xml inside a CBZ (ZIP) file."""
try:
with zf.ZipFile(path, "r") as z:
names = {n.lower(): n for n in z.namelist()}
ci_key = names.get("comicinfo.xml")
if ci_key is None:
return 0
xml = z.read(ci_key).decode("utf-8", errors="replace")
m = re.search(r"<NovelaRating>(\d+)</NovelaRating>", xml)
if m:
return max(0, min(5, int(m.group(1))))
except Exception:
pass
return 0
def scan_media(path: Path) -> dict: def scan_media(path: Path) -> dict:
mt = media_type_from_suffix(path) mt = media_type_from_suffix(path)
if mt == "epub": if mt == "epub":
@ -271,6 +345,8 @@ def scan_media(path: Path) -> dict:
"publish_date": "", "publish_date": "",
"subjects": [], "subjects": [],
} }
if path.suffix.lower() == ".cbz":
meta["rating"] = scan_cbz_rating(path)
else: else:
meta = {} meta = {}
meta["media_type"] = mt meta["media_type"] = mt
@ -281,38 +357,44 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
""" """
INSERT INTO library (filename, media_type, title, author, publisher, has_cover, INSERT INTO library (filename, media_type, storage_type, title, author, publisher, has_cover,
series, series_index, publication_status, source_url, series, series_index, series_suffix, publication_status, source_url,
publish_date, description, needs_review, want_to_read, updated_at) publish_date, description, needs_review, want_to_read, rating, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, %s, NOW())
ON CONFLICT (filename) DO UPDATE SET ON CONFLICT (filename) DO UPDATE SET
media_type = EXCLUDED.media_type, media_type = EXCLUDED.media_type,
storage_type = EXCLUDED.storage_type,
title = COALESCE(NULLIF(EXCLUDED.title, ''), library.title), title = COALESCE(NULLIF(EXCLUDED.title, ''), library.title),
author = COALESCE(NULLIF(EXCLUDED.author, ''), library.author), author = COALESCE(NULLIF(EXCLUDED.author, ''), library.author),
publisher = COALESCE(NULLIF(EXCLUDED.publisher, ''), library.publisher), publisher = COALESCE(NULLIF(EXCLUDED.publisher, ''), library.publisher),
has_cover = (library.has_cover OR EXCLUDED.has_cover), has_cover = (library.has_cover OR EXCLUDED.has_cover),
series = COALESCE(NULLIF(EXCLUDED.series, ''), library.series), series = COALESCE(NULLIF(EXCLUDED.series, ''), library.series),
series_index = CASE WHEN COALESCE(EXCLUDED.series_index, 0) > 0 THEN EXCLUDED.series_index ELSE library.series_index END, series_index = CASE WHEN COALESCE(EXCLUDED.series_index, 0) > 0 THEN EXCLUDED.series_index ELSE library.series_index END,
series_suffix = COALESCE(NULLIF(EXCLUDED.series_suffix, ''), library.series_suffix),
publication_status = COALESCE(NULLIF(EXCLUDED.publication_status, ''), library.publication_status), publication_status = COALESCE(NULLIF(EXCLUDED.publication_status, ''), library.publication_status),
source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), library.source_url), source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), library.source_url),
publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date), publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date),
description = COALESCE(NULLIF(EXCLUDED.description, ''), library.description), description = COALESCE(NULLIF(EXCLUDED.description, ''), library.description),
rating = CASE WHEN EXCLUDED.rating > 0 THEN EXCLUDED.rating ELSE library.rating END,
updated_at = NOW() updated_at = NOW()
""", """,
( (
filename, filename,
meta.get("media_type", "epub"), meta.get("media_type", "epub"),
meta.get("storage_type", "file"),
meta.get("title", ""), meta.get("title", ""),
meta.get("author", ""), meta.get("author", ""),
meta.get("publisher", ""), meta.get("publisher", ""),
bool(meta.get("has_cover", False)), bool(meta.get("has_cover", False)),
meta.get("series", ""), meta.get("series", ""),
meta.get("series_index", 0), meta.get("series_index", 0),
meta.get("series_suffix", ""),
meta.get("publication_status", ""), meta.get("publication_status", ""),
meta.get("source_url", ""), meta.get("source_url", ""),
meta.get("publish_date") or None, meta.get("publish_date") or None,
meta.get("description", ""), meta.get("description", ""),
bool(meta.get("needs_review", False)), bool(meta.get("needs_review", False)),
max(0, min(5, int(meta.get("rating", 0) or 0))),
), ),
) )
@ -348,7 +430,13 @@ def list_library_json() -> list[dict]:
rp.progress, rp.cfi, rp.page, rp.progress, rp.cfi, rp.page,
COALESCE(rs.read_count, 0)::int AS read_count, COALESCE(rs.read_count, 0)::int AS read_count,
rs.last_read, rs.last_read,
(cc.filename IS NOT NULL) AS has_cached_cover (cc.filename IS NOT NULL) AS has_cached_cover,
l.rating,
COALESCE(l.series_suffix, '') AS series_suffix,
COALESCE(l.storage_type, 'file') AS storage_type,
json_agg(
json_build_object('tag', bt.tag, 'tag_type', bt.tag_type)
) FILTER (WHERE bt.tag IS NOT NULL) AS tags
FROM library l FROM library l
LEFT JOIN reading_progress rp ON rp.filename = l.filename LEFT JOIN reading_progress rp ON rp.filename = l.filename
LEFT JOIN ( LEFT JOIN (
@ -357,16 +445,17 @@ def list_library_json() -> list[dict]:
GROUP BY filename GROUP BY filename
) rs ON rs.filename = l.filename ) rs ON rs.filename = l.filename
LEFT JOIN library_cover_cache cc ON cc.filename = l.filename LEFT JOIN library_cover_cache cc ON cc.filename = l.filename
LEFT JOIN book_tags bt ON bt.filename = l.filename
GROUP BY l.filename, l.media_type, l.title, l.author, l.publisher, l.has_cover,
l.series, l.series_index, l.publication_status, l.want_to_read,
l.archived, l.needs_review, l.updated_at,
rp.progress, rp.cfi, rp.page,
rs.read_count, rs.last_read,
cc.filename, l.rating, l.series_suffix, l.storage_type
ORDER BY COALESCE(l.publisher, ''), COALESCE(l.author, ''), COALESCE(l.series, ''), l.series_index, COALESCE(l.title, '') ORDER BY COALESCE(l.publisher, ''), COALESCE(l.author, ''), COALESCE(l.series, ''), l.series_index, COALESCE(l.title, '')
""" """
) )
rows = cur.fetchall() rows = cur.fetchall()
cur.execute("SELECT filename, tag, tag_type FROM book_tags ORDER BY filename, tag")
tags = cur.fetchall()
tag_map: dict[str, list[dict]] = {}
for filename, tag, tag_type in tags:
tag_map.setdefault(filename, []).append({"tag": tag, "tag_type": tag_type})
out = [] out = []
for r in rows: for r in rows:
@ -381,6 +470,7 @@ def list_library_json() -> list[dict]:
"has_cached_cover": bool(r[18]), "has_cached_cover": bool(r[18]),
"series": r[6] or "", "series": r[6] or "",
"series_index": r[7] or 0, "series_index": r[7] or 0,
"series_suffix": r[20] or "",
"publication_status": r[8] or "", "publication_status": r[8] or "",
"want_to_read": bool(r[9]), "want_to_read": bool(r[9]),
"archived": bool(r[10]), "archived": bool(r[10]),
@ -391,28 +481,90 @@ def list_library_json() -> list[dict]:
"page": r[15], "page": r[15],
"read_count": r[16] or 0, "read_count": r[16] or 0,
"last_read": r[17].isoformat() if r[17] else None, "last_read": r[17].isoformat() if r[17] else None,
"tags": tag_map.get(r[0], []), "storage_type": r[21] or "file",
"tags": r[22] or [],
"rating": r[19] or 0,
} }
) )
return out return out
def ensure_cover_missing_tag(conn, filename: str, has_cover: bool) -> None: _IMAGE_EXT_MAP = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
}
def write_image_file(data: bytes, media_type: str) -> tuple[str, str, str]:
"""Write image bytes to the content-addressed imagestore (no DB).
Returns (sha256, ext, url).
"""
sha256 = hashlib.sha256(data).hexdigest()
ext = _IMAGE_EXT_MAP.get(media_type, ".jpg")
img_path = IMAGES_DIR / sha256[:2] / f"{sha256}{ext}"
if not img_path.exists():
img_path.parent.mkdir(parents=True, exist_ok=True)
img_path.write_bytes(data)
url = f"/library/db-images/{sha256[:2]}/{sha256}{ext}"
return sha256, ext, url
def store_db_image(conn, data: bytes, media_type: str) -> tuple[str, str, str]:
"""Write image to imagestore and register in book_images table.
Returns (sha256, ext, url).
"""
sha256, ext, url = write_image_file(data, media_type)
with conn.cursor() as cur: with conn.cursor() as cur:
if has_cover:
cur.execute(
"DELETE FROM book_tags WHERE filename = %s AND tag = 'Cover Missing' AND tag_type = 'tag'",
(filename,),
)
return
cur.execute( cur.execute(
""" """
INSERT INTO book_tags (filename, tag, tag_type) INSERT INTO book_images (sha256, ext, media_type, size_bytes)
VALUES (%s, 'Cover Missing', 'tag') VALUES (%s, %s, %s, %s)
ON CONFLICT (filename, tag, tag_type) DO NOTHING ON CONFLICT (sha256) DO NOTHING
""", """,
(filename,), (sha256, ext, media_type, len(data)),
) )
return sha256, ext, url
def html_to_plain(html: str) -> str:
"""Strip HTML tags for tsvector input."""
from bs4 import BeautifulSoup
return BeautifulSoup(html, "html.parser").get_text(" ", strip=True)
def upsert_chapter(conn, filename: str, chapter_index: int, title: str, content_html: str) -> None:
"""Insert or replace a chapter in book_chapters and update its tsvector."""
plain = html_to_plain(content_html)
tsv_input = (title or "") + " " + plain
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO book_chapters (filename, chapter_index, title, content, content_tsv)
VALUES (%s, %s, %s, %s, to_tsvector('simple', %s))
ON CONFLICT (filename, chapter_index) DO UPDATE SET
title = EXCLUDED.title,
content = EXCLUDED.content,
content_tsv = EXCLUDED.content_tsv
""",
(filename, chapter_index, title, content_html, tsv_input),
)
def ensure_unique_db_filename(conn, base_filename: str) -> str:
"""Return a filename that doesn't yet exist in the library table."""
candidate = base_filename
counter = 2
while True:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM library WHERE filename = %s", (candidate,))
if not cur.fetchone():
return candidate
candidate = f"{base_filename} ({counter})"
counter += 1
def normalize_site(raw: str) -> str: def normalize_site(raw: str) -> str:

View File

@ -12,7 +12,7 @@ from fastapi.templating import Jinja2Templates
from db import get_db_conn from db import get_db_conn
from epub import read_epub_file, write_epub_file from epub import read_epub_file, write_epub_file
from routers.common import LIBRARY_DIR, resolve_library_path from routers.common import LIBRARY_DIR, is_db_filename, resolve_library_path, upsert_chapter
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@ -158,21 +158,40 @@ def _rewrite_epub_entries(epub_path: Path, updates: dict[str, bytes], remove_pat
@router.get("/library/editor/{filename:path}", response_class=HTMLResponse) @router.get("/library/editor/{filename:path}", response_class=HTMLResponse)
async def editor_page(filename: str, request: Request): async def editor_page(filename: str, request: Request):
path = resolve_library_path(filename) if not is_db_filename(filename):
if path is None or not path.exists(): path = resolve_library_path(filename)
return HTMLResponse("Not found", status_code=404) if path is None or not path.exists():
return HTMLResponse("Not found", status_code=404)
with get_db_conn() as conn: with get_db_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT title FROM library WHERE filename = %s", (filename,)) cur.execute("SELECT title FROM library WHERE filename = %s", (filename,))
row = cur.fetchone() row = cur.fetchone()
title = row[0] if row and row[0] else filename if not row:
return HTMLResponse("Not found", status_code=404)
title = row[0] if row[0] else filename
return templates.TemplateResponse(request, "editor.html", {"filename": filename, "title": title}) return templates.TemplateResponse(request, "editor.html", {
"filename": filename,
"title": title,
"is_db": is_db_filename(filename),
})
@router.get("/api/edit/chapter/{index:int}/{filename:path}") @router.get("/api/edit/chapter/{index:int}/{filename:path}")
async def get_edit_chapter(filename: str, index: int): async def get_edit_chapter(filename: str, index: int):
if is_db_filename(filename):
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT title, content FROM book_chapters WHERE filename = %s AND chapter_index = %s",
(filename, index),
)
row = cur.fetchone()
if not row:
return Response(status_code=404)
return JSONResponse({"index": index, "href": f"db:{index}", "title": row[0], "content": row[1]})
path = resolve_library_path(filename) path = resolve_library_path(filename)
if path is None or not path.exists(): if path is None or not path.exists():
return Response(status_code=404) return Response(status_code=404)
@ -186,13 +205,29 @@ async def get_edit_chapter(filename: str, index: int):
@router.post("/api/edit/chapter/{index:int}/{filename:path}") @router.post("/api/edit/chapter/{index:int}/{filename:path}")
async def save_edit_chapter(filename: str, index: int, request: Request): async def save_edit_chapter(filename: str, index: int, request: Request):
body = await request.json()
content = body.get("content", "")
if is_db_filename(filename):
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT title FROM book_chapters WHERE filename = %s AND chapter_index = %s",
(filename, index),
)
row = cur.fetchone()
if not row:
return JSONResponse({"error": "Chapter not found"}, status_code=404)
new_title = (body.get("title") or "").strip() or row[0]
with conn:
upsert_chapter(conn, filename, index, new_title, content)
return JSONResponse({"ok": True})
path = resolve_library_path(filename) path = resolve_library_path(filename)
if path is None: if path is None:
return JSONResponse({"error": "not found"}, status_code=404) return JSONResponse({"error": "not found"}, status_code=404)
if not path.exists(): if not path.exists():
return JSONResponse({"error": "File not found"}, status_code=404) return JSONResponse({"error": "File not found"}, status_code=404)
body = await request.json()
content = body.get("content", "")
if not content: if not content:
return JSONResponse({"error": "No content"}, status_code=400) return JSONResponse({"error": "No content"}, status_code=400)
spine = _epub_spine(path) spine = _epub_spine(path)
@ -208,15 +243,42 @@ async def save_edit_chapter(filename: str, index: int, request: Request):
@router.post("/api/edit/chapter/add/{filename:path}") @router.post("/api/edit/chapter/add/{filename:path}")
async def add_edit_chapter(filename: str, request: Request): async def add_edit_chapter(filename: str, request: Request):
body = await request.json()
title = (body.get("title") or "New chapter").strip() or "New chapter"
after_index = body.get("after_index", -1)
if is_db_filename(filename):
try:
after_index = int(after_index)
except Exception:
after_index = -1
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM book_chapters WHERE filename = %s", (filename,))
total = cur.fetchone()[0]
cur.execute("SELECT 1 FROM library WHERE filename = %s", (filename,))
if not cur.fetchone():
return JSONResponse({"error": "not found"}, status_code=404)
insert_idx = total if after_index < 0 or after_index >= total else after_index + 1
with conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE book_chapters SET chapter_index = chapter_index + 1 WHERE filename = %s AND chapter_index >= %s",
(filename, insert_idx),
)
upsert_chapter(conn, filename, insert_idx, title, "")
return JSONResponse({"ok": True, "index": insert_idx, "count": total + 1})
path = resolve_library_path(filename) path = resolve_library_path(filename)
if path is None: if path is None:
return JSONResponse({"error": "not found"}, status_code=404) return JSONResponse({"error": "not found"}, status_code=404)
if not path.exists(): if not path.exists():
return JSONResponse({"error": "File not found"}, status_code=404) return JSONResponse({"error": "File not found"}, status_code=404)
body = await request.json() try:
title = (body.get("title") or "New chapter").strip() or "New chapter" after_index = int(after_index)
after_index = body.get("after_index", -1) except Exception:
after_index = -1
try: try:
after_index = int(after_index) after_index = int(after_index)
except Exception: except Exception:
@ -339,6 +401,26 @@ async def add_edit_chapter(filename: str, request: Request):
@router.delete("/api/edit/chapter/{index:int}/{filename:path}") @router.delete("/api/edit/chapter/{index:int}/{filename:path}")
async def delete_edit_chapter(filename: str, index: int): async def delete_edit_chapter(filename: str, index: int):
if is_db_filename(filename):
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM book_chapters WHERE filename = %s", (filename,))
total = cur.fetchone()[0]
if total <= 1:
return JSONResponse({"error": "Cannot delete the last chapter"}, status_code=400)
with conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM book_chapters WHERE filename = %s AND chapter_index = %s",
(filename, index),
)
cur.execute(
"UPDATE book_chapters SET chapter_index = chapter_index - 1 WHERE filename = %s AND chapter_index > %s",
(filename, index),
)
new_total = total - 1
return JSONResponse({"ok": True, "index": min(index, new_total - 1), "count": new_total})
path = resolve_library_path(filename) path = resolve_library_path(filename)
if path is None: if path is None:
return JSONResponse({"error": "not found"}, status_code=404) return JSONResponse({"error": "not found"}, status_code=404)

View File

@ -0,0 +1,68 @@
from urllib.parse import unquote
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from db import get_db_conn
templates = Jinja2Templates(directory="templates")
router = APIRouter()
@router.get("/following", response_class=HTMLResponse)
async def following_page(request: Request):
return templates.TemplateResponse(request, "following.html", {"active": "following"})
@router.get("/api/following")
async def get_following():
"""Return all distinct library authors with their URL (if any) and book stats."""
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
l.author,
COUNT(l.filename)::int AS book_count,
MAX(l.created_at) AS last_added,
a.url
FROM library l
LEFT JOIN authors a ON a.name = l.author
WHERE l.author IS NOT NULL AND l.author <> '' AND NOT l.archived
GROUP BY l.author, a.url
ORDER BY l.author
"""
)
return [
{
"name": r[0],
"book_count": r[1],
"last_added": r[2].isoformat() if r[2] else None,
"url": r[3],
}
for r in cur.fetchall()
]
@router.post("/api/following/{author_name:path}")
async def set_author_url(author_name: str, request: Request):
"""Set or clear the URL for an author (empty url removes the entry)."""
author_name = unquote(author_name)
body = await request.json()
url = (body.get("url") or "").strip()
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
if url:
cur.execute(
"""
INSERT INTO authors (name, url)
VALUES (%s, %s)
ON CONFLICT (name) DO UPDATE SET url = EXCLUDED.url, updated_at = NOW()
""",
(author_name, url),
)
else:
cur.execute("DELETE FROM authors WHERE name = %s", (author_name,))
return {"ok": True}

View File

@ -17,12 +17,16 @@ from db import get_db_conn
from epub import detect_image_format, make_chapter_xhtml, make_epub from epub import detect_image_format, make_chapter_xhtml, make_epub
from routers.common import ( from routers.common import (
LIBRARY_DIR, LIBRARY_DIR,
ensure_cover_cache_for_book, ensure_unique_db_filename,
ensure_cover_missing_tag,
ensure_unique_rel_path, ensure_unique_rel_path,
make_cover_thumb_webp,
make_rel_path, make_rel_path,
normalize_site, normalize_site,
store_db_image,
upsert_book, upsert_book,
upsert_chapter,
upsert_cover_cache,
write_image_file,
) )
from scrapers import get_scraper from scrapers import get_scraper
from scrapers.base import HEADERS from scrapers.base import HEADERS
@ -136,22 +140,87 @@ async def debug_run(request: Request):
result: dict = {} result: dict = {}
try: try:
async with httpx.AsyncClient(headers=HEADERS, follow_redirects=True, timeout=30) as client: async with httpx.AsyncClient(headers=HEADERS, follow_redirects=True, timeout=30) as client:
# Login
login_success = False
if username: if username:
await scraper.login(client, username, password) login_success = await scraper.login(client, username, password)
result["login"] = {
"attempted": bool(username),
"success": login_success,
"username": username,
}
book = await scraper.fetch_book_info(client, url) book = await scraper.fetch_book_info(client, url)
result = { chapters = book.get("chapters", [])
# Compute output filename
series = book.get("series", "")
series_index = int(book.get("series_index_hint", 1) or 1)
filename = make_rel_path(
media_type="epub",
publisher=book.get("publisher", ""),
author=book.get("author", ""),
title=book.get("title", ""),
series=series,
series_index=series_index,
).as_posix()
result["meta"] = {
"title": book.get("title", ""), "title": book.get("title", ""),
"author": book.get("author", ""), "author": book.get("author", ""),
"publisher": book.get("publisher", ""), "publisher": book.get("publisher", ""),
"series": book.get("series", ""), "series": book.get("series", ""),
"chapter_count": len(book.get("chapters", [])),
"chapter_method": book.get("chapter_method", ""),
"genres": book.get("genres", []), "genres": book.get("genres", []),
"subgenres": book.get("subgenres", []), "subgenres": book.get("subgenres", []),
"tags": book.get("tags", []), "tags": book.get("tags", []),
"description": book.get("description", ""), "description": book.get("description", ""),
"updated_date": book.get("updated_date", ""),
"publication_status": book.get("publication_status", ""), "publication_status": book.get("publication_status", ""),
"filename": filename,
} }
result["chapters"] = {
"count": len(chapters),
"method": book.get("chapter_method", ""),
"list": chapters,
}
# Fetch first chapter
if chapters:
ch = chapters[0]
try:
_load_break_patterns()
ch_data = await scraper.fetch_chapter(client, ch)
content_el = ch_data.get("content_el")
raw_html = content_el.decode_contents() if content_el else ""
xhtml_parts = []
if content_el:
from bs4 import Tag
all_p = content_el.find_all("p")
empty_p = sum(
1 for p in all_p
if not [c for c in p.children if isinstance(c, Tag)]
and not p.get_text().replace("\xa0", "").strip()
)
filled_p = len(all_p) - empty_p
empty_p_is_spacer = filled_p > 0 and empty_p >= filled_p * 0.5
for child in content_el.children:
part = element_to_xhtml(child, empty_p_is_spacer=empty_p_is_spacer)
if part.strip():
xhtml_parts.append(part)
result["first_chapter"] = {
"title": ch_data.get("title", ch["title"]),
"url": ch["url"],
"selector_id": ch_data.get("selector_id"),
"selector_class": ch_data.get("selector_class"),
"raw_html": raw_html[:8000],
"converted_xhtml": "\n".join(xhtml_parts)[:8000],
}
except Exception as e:
result["first_chapter"] = {"title": ch["title"], "url": ch["url"], "error": str(e)}
except Exception: except Exception:
result["error"] = traceback.format_exc() result["error"] = traceback.format_exc()
return result return result
@ -217,9 +286,37 @@ async def preload(request: Request):
book = await scraper.fetch_book_info(client, url) book = await scraper.fetch_book_info(client, url)
series = book.get("series", "") series = book.get("series", "")
hint = int(book.get("series_index_hint", 0) or 0) hint = int(book.get("series_index_hint", 0) or 0)
title = book.get("title", "")
author = book.get("author", "")
existing_books = []
if title or author:
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""SELECT filename, title, author, series_index FROM library
WHERE LOWER(TRIM(title)) = LOWER(TRIM(%s))
AND LOWER(TRIM(author)) = LOWER(TRIM(%s))""",
(title, author),
)
rows = cur.fetchall()
if hint:
# Volume known: only a duplicate when title+author+volume all match
existing_books = [
{"filename": r[0], "title": r[1] or "", "author": r[2] or ""}
for r in rows if r[3] == hint
]
else:
# No volume: duplicate if any title+author match exists
existing_books = [
{"filename": r[0], "title": r[1] or "", "author": r[2] or ""}
for r in rows
]
return { return {
"title": book.get("title", ""), "title": title,
"author": book.get("author", ""), "author": author,
"publisher": book.get("publisher", ""), "publisher": book.get("publisher", ""),
"series": series, "series": series,
"series_index_next": hint if hint else _next_series_index(series), "series_index_next": hint if hint else _next_series_index(series),
@ -229,6 +326,8 @@ async def preload(request: Request):
"description": book.get("description", ""), "description": book.get("description", ""),
"updated_date": book.get("updated_date", ""), "updated_date": book.get("updated_date", ""),
"publication_status": book.get("publication_status", ""), "publication_status": book.get("publication_status", ""),
"already_exists": bool(existing_books),
"existing_books": existing_books,
} }
@ -274,10 +373,7 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
tags = list(book.get("tags", [])) tags = list(book.get("tags", []))
if len(book["chapters"]) < 4 and "Shorts" not in tags: if len(book["chapters"]) < 4 and "Shorts" not in tags:
tags.append("Shorts") tags.append("Shorts")
if cover_data is None and "Cover Missing" not in tags: status_map = {"Temporary-Hold": "Temporary Hold"}
tags.append("Cover Missing")
status_map = {"Long-Term Hold": "Hiatus"}
pub_status = status_map.get(book.get("publication_status", ""), book.get("publication_status", "")) pub_status = status_map.get(book.get("publication_status", ""), book.get("publication_status", ""))
series = book.get("series", "") series = book.get("series", "")
@ -304,8 +400,8 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
} }
_load_break_patterns() _load_break_patterns()
break_img_data = open("static/break.png", "rb").read()
# Collect chapters as {title, content_html, images: [(sha256, ext, media_type, size, data)]}
chapters = [] chapters = []
for i, ch in enumerate(book["chapters"], 1): for i, ch in enumerate(book["chapters"], 1):
send("progress", {"current": i, "total": len(book["chapters"]), "title": ch["title"]}) send("progress", {"current": i, "total": len(book["chapters"]), "title": ch["title"]})
@ -313,11 +409,11 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
ch_data = await scraper.fetch_chapter(client, ch) ch_data = await scraper.fetch_chapter(client, ch)
content_el = ch_data["content_el"] content_el = ch_data["content_el"]
chapter_images = [] # Download images and store to disk (no DB yet); rewrite src to absolute URL
if content_el: if content_el:
img_counter = 1
for img_tag in content_el.find_all("img"): for img_tag in content_el.find_all("img"):
if is_break_element(img_tag): if is_break_element(img_tag):
img_tag.decompose()
continue continue
src = img_tag.get("src", "") src = img_tag.get("src", "")
if not src or src.startswith("data:"): if not src or src.startswith("data:"):
@ -326,19 +422,16 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
try: try:
img_resp = await client.get(urljoin(ch["url"], src)) img_resp = await client.get(urljoin(ch["url"], src))
if img_resp.status_code == 200: if img_resp.status_code == 200:
img_name, img_mime = detect_image_format( _, img_mime = detect_image_format(
img_resp.content, f"ch{i:03d}_img{img_counter:03d}" img_resp.content, f"ch{i:03d}_img"
) )
img_tag["src"] = f"../Images/{img_name}" sha, ext_i, url = write_image_file(img_resp.content, img_mime)
img_tag["src"] = url
img_tag["alt"] = img_tag.get("alt", "") img_tag["alt"] = img_tag.get("alt", "")
chapter_images.append( img_tag.attrs = {
{ k: v for k, v in img_tag.attrs.items()
"epub_path": f"OEBPS/Images/{img_name}", if k in ("src", "alt", "width", "height")
"data": img_resp.content, }
"media_type": img_mime,
}
)
img_counter += 1
else: else:
img_tag.decompose() img_tag.decompose()
except Exception: except Exception:
@ -360,9 +453,8 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
if part.strip(): if part.strip():
xhtml_parts.append(part) xhtml_parts.append(part)
content_xhtml = "\n".join(xhtml_parts) content_html = "\n".join(xhtml_parts)
chapter_xhtml = make_chapter_xhtml(ch_data["title"], content_xhtml, i) chapters.append({"title": ch_data["title"], "content_html": content_html})
chapters.append({"title": ch_data["title"], "xhtml": chapter_xhtml, "images": chapter_images})
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
except Exception as e: except Exception as e:
send("warning", {"message": f"Chapter {i} skipped: {e}"}) send("warning", {"message": f"Chapter {i} skipped: {e}"})
@ -372,12 +464,30 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
job["done"] = True job["done"] = True
return return
send("status", {"message": "Building EPUB..."}) storage_mode = job.get("storage_mode", "db")
book_id = str(uuid.uuid4()) send("status", {"message": "Saving to library..."})
epub_bytes = make_epub(book_title, author, chapters, cover_data, break_img_data, book_id, book_info)
rel = ensure_unique_rel_path( book_tags = (
make_rel_path( [(g, "genre") for g in book_info.get("genres", [])]
+ [(g, "subgenre") for g in book_info.get("subgenres", [])]
+ [(g, "tag") for g in book_info.get("tags", [])]
)
if storage_mode == "epub":
# ── EPUB file on disk ──────────────────────────────────────────
epub_chapters = [
{"title": ch["title"], "xhtml": make_chapter_xhtml(ch["title"], ch["content_html"], i + 1), "images": []}
for i, ch in enumerate(chapters)
]
try:
break_img_data = open("static/break.png", "rb").read()
except Exception:
break_img_data = b""
epub_bytes = make_epub(
book_title, author, epub_chapters, cover_data, break_img_data,
str(uuid.uuid4()), book_info,
)
rel_path = make_rel_path(
media_type="epub", media_type="epub",
publisher=book_info.get("publisher", ""), publisher=book_info.get("publisher", ""),
author=author, author=author,
@ -385,41 +495,78 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
series=series, series=series,
series_index=series_index, series_index=series_index,
) )
) rel_path = ensure_unique_rel_path(rel_path)
out_path = LIBRARY_DIR / rel out_path = LIBRARY_DIR / rel_path
out_path.parent.mkdir(parents=True, exist_ok=True) out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(epub_bytes) out_path.write_bytes(epub_bytes)
rel_filename = rel_path.as_posix()
book_meta = {
"media_type": "epub",
"storage_type": "file",
"has_cover": cover_data is not None,
"series": series,
"series_index": series_index if series else 0,
"title": book_title,
"publication_status": book_info.get("publication_status", ""),
"author": author,
"publisher": book_info.get("publisher", ""),
"source_url": book_info.get("source_url", ""),
"description": book_info.get("description", ""),
"publish_date": final_updated_date,
"needs_review": False,
}
with get_db_conn() as conn:
with conn:
upsert_book(conn, rel_filename, book_meta, book_tags)
if cover_data:
try:
thumb = make_cover_thumb_webp(cover_data)
upsert_cover_cache(conn, rel_filename, "image/webp", thumb)
except Exception:
pass
else:
# ── DB storage (default) ───────────────────────────────────────
base_filename = make_rel_path(
media_type="db",
publisher=book_info.get("publisher", ""),
author=author,
title=book_title,
series=series,
series_index=series_index,
).as_posix()
book_meta = {
"media_type": "epub",
"storage_type": "db",
"has_cover": cover_data is not None,
"series": book_info.get("series", ""),
"series_index": series_index if book_info.get("series") else 0,
"title": book_title,
"publication_status": book_info.get("publication_status", ""),
"author": author,
"publisher": book_info.get("publisher", ""),
"source_url": book_info.get("source_url", ""),
"description": book_info.get("description", ""),
"publish_date": final_updated_date,
"needs_review": False,
}
with get_db_conn() as conn:
with conn:
rel_filename = ensure_unique_db_filename(conn, base_filename)
upsert_book(conn, rel_filename, book_meta, book_tags)
for idx, ch in enumerate(chapters):
upsert_chapter(conn, rel_filename, idx, ch["title"], ch["content_html"])
if cover_data:
try:
thumb = make_cover_thumb_webp(cover_data)
upsert_cover_cache(conn, rel_filename, "image/webp", thumb)
except Exception:
pass
rel_filename = rel.as_posix()
job["filename"] = rel_filename job["filename"] = rel_filename
send("done", {"filename": rel_filename, "title": book_title, "chapters": len(chapters), "storage_type": storage_mode})
book_meta = {
"media_type": "epub",
"has_cover": cover_data is not None,
"series": book_info.get("series", ""),
"series_index": series_index if book_info.get("series") else 0,
"title": book_title,
"publication_status": book_info.get("publication_status", ""),
"author": author,
"publisher": book_info.get("publisher", ""),
"source_url": book_info.get("source_url", ""),
"description": book_info.get("description", ""),
"publish_date": final_updated_date,
"needs_review": False,
}
book_tags = (
[(g, "genre") for g in book_info.get("genres", [])]
+ [(g, "subgenre") for g in book_info.get("subgenres", [])]
+ [(g, "tag") for g in book_info.get("tags", [])]
)
with get_db_conn() as conn:
with conn:
upsert_book(conn, rel_filename, book_meta, book_tags)
ensure_cover_missing_tag(conn, rel_filename, bool(book_meta["has_cover"]))
ensure_cover_cache_for_book(conn, rel_filename, out_path, "epub")
send("done", {"filename": rel_filename, "title": book_title, "chapters": len(chapters)})
job["done"] = True job["done"] = True
@ -446,6 +593,7 @@ async def convert(request: Request):
job["series_index"] = int(body.get("series_index", 1) or 1) job["series_index"] = int(body.get("series_index", 1) or 1)
job["updated_date_override"] = (body.get("updated_date") or "").strip() job["updated_date_override"] = (body.get("updated_date") or "").strip()
job["storage_mode"] = "epub" if body.get("storage_mode") == "epub" else "db"
JOBS[job_id] = job JOBS[job_id] = job
asyncio.create_task(scrape_book(job_id, url, username, password)) asyncio.create_task(scrape_book(job_id, url, username, password))

View File

@ -1,10 +1,11 @@
import base64 import base64
import shutil
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, File, Request, UploadFile from fastapi import APIRouter, File, Request, UploadFile
from fastapi.responses import HTMLResponse, Response from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from PIL import UnidentifiedImageError from PIL import UnidentifiedImageError
@ -13,8 +14,8 @@ from epub import add_cover_to_epub
from routers.common import ( from routers.common import (
LIBRARY_DIR, LIBRARY_DIR,
ensure_cover_cache_for_book, ensure_cover_cache_for_book,
ensure_cover_missing_tag,
ensure_unique_rel_path, ensure_unique_rel_path,
is_db_filename,
list_library_json, list_library_json,
make_cover_thumb_webp, make_cover_thumb_webp,
make_rel_path, make_rel_path,
@ -50,7 +51,6 @@ def _sync_disk_to_db() -> int:
continue continue
tags = [(s, "subject") for s in meta.get("subjects", [])] tags = [(s, "subject") for s in meta.get("subjects", [])]
upsert_book(conn, rel, meta, tags) upsert_book(conn, rel, meta, tags)
ensure_cover_missing_tag(conn, rel, bool(meta.get("has_cover")))
if bool(meta.get("has_cover")): if bool(meta.get("has_cover")):
ensure_cover_cache_for_book(conn, rel, p, meta["media_type"]) ensure_cover_cache_for_book(conn, rel, p, meta["media_type"])
synced += 1 synced += 1
@ -71,19 +71,33 @@ async def library_page(request: Request):
@router.get("/api/library") @router.get("/api/library")
async def api_library(rescan: bool = False, include_file_info: bool = False): async def api_library(
request: Request = None,
rescan: bool = False,
include_file_info: bool = False,
):
# Fast path: avoid expensive full disk scan on every library page load. # Fast path: avoid expensive full disk scan on every library page load.
# Use /library/rescan (or ?rescan=true) when a full sync is needed. # Use /library/rescan (or ?rescan=true) when a full sync is needed.
if rescan: if rescan:
_sync_disk_to_db() _sync_disk_to_db()
# ETag based on row count + latest updated_at — cheap query before full load.
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*), MAX(updated_at) FROM library")
_count, _max_ts = cur.fetchone()
etag = f'"{_count}-{int(_max_ts.timestamp()) if _max_ts else 0}"'
if request and request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
books = list_library_json() books = list_library_json()
if include_file_info: if include_file_info:
for b in books: for b in books:
p = resolve_library_path(b["filename"]) p = resolve_library_path(b["filename"])
if p and p.exists(): if p and p.exists():
b.update(relative_file_info(p)) b.update(relative_file_info(p))
return books return JSONResponse(content=books, headers={"ETag": etag, "Cache-Control": "no-cache"})
@router.post("/library/rescan") @router.post("/library/rescan")
@ -130,6 +144,8 @@ async def library_import(files: list[UploadFile] = File(...)):
title=meta.get("title") or Path(name).stem, title=meta.get("title") or Path(name).stem,
series=meta.get("series", ""), series=meta.get("series", ""),
series_index=meta.get("series_index", 0), series_index=meta.get("series_index", 0),
series_suffix=meta.get("series_suffix", ""),
ext=suffix,
) )
) )
dest = LIBRARY_DIR / rel dest = LIBRARY_DIR / rel
@ -140,7 +156,6 @@ async def library_import(files: list[UploadFile] = File(...)):
meta["needs_review"] = True meta["needs_review"] = True
tags = [(s, "subject") for s in meta.get("subjects", [])] tags = [(s, "subject") for s in meta.get("subjects", [])]
upsert_book(conn, rel_name, meta, tags) upsert_book(conn, rel_name, meta, tags)
ensure_cover_missing_tag(conn, rel_name, bool(meta.get("has_cover")))
ensure_cover_cache_for_book(conn, rel_name, dest, media_type) ensure_cover_cache_for_book(conn, rel_name, dest, media_type)
imported.append(rel_name) imported.append(rel_name)
except Exception as e: except Exception as e:
@ -151,8 +166,27 @@ async def library_import(files: list[UploadFile] = File(...)):
return {"ok": True, "imported": imported, "skipped": skipped} return {"ok": True, "imported": imported, "skipped": skipped}
@router.get("/download/{filename:path}")
async def library_download(filename: str):
full = resolve_library_path(filename)
if full is None or not full.exists():
return Response(status_code=404)
return FileResponse(full, filename=full.name, media_type="application/octet-stream")
@router.delete("/library/file/{filename:path}") @router.delete("/library/file/{filename:path}")
async def library_delete(filename: str): async def library_delete(filename: str):
if is_db_filename(filename):
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM library WHERE filename = %s", (filename,))
if not cur.fetchone():
return {"error": "Not found"}
with conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM library WHERE filename = %s", (filename,))
return {"ok": True}
full = resolve_library_path(filename) full = resolve_library_path(filename)
if full is None: if full is None:
return {"error": "Invalid filename"} return {"error": "Invalid filename"}
@ -170,11 +204,51 @@ async def library_delete(filename: str):
return {"ok": True} return {"ok": True}
@router.post("/library/bulk-delete")
async def library_bulk_delete(request: Request):
body = await request.json()
filenames = body.get("filenames", [])
if not isinstance(filenames, list):
return JSONResponse({"error": "filenames must be a list"}, status_code=400)
deleted: list[str] = []
skipped: list[str] = []
for filename in filenames:
if not isinstance(filename, str):
continue
full = resolve_library_path(filename)
if full is None:
skipped.append(filename)
continue
try:
if full.exists():
parent = full.parent
full.unlink()
prune_empty_dirs(parent)
deleted.append(filename)
except Exception:
skipped.append(filename)
if deleted:
placeholders = ", ".join(["%s"] * len(deleted))
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
f"DELETE FROM library WHERE filename IN ({placeholders})",
tuple(deleted),
)
return {"ok": True, "deleted": len(deleted), "skipped": skipped}
@router.get("/library/cover-cached/{filename:path}") @router.get("/library/cover-cached/{filename:path}")
async def library_cover_cached(filename: str): async def library_cover_cached(filename: str):
full = resolve_library_path(filename) if not is_db_filename(filename):
if full is None or not full.exists(): full = resolve_library_path(filename)
return Response(status_code=404) if full is None or not full.exists():
return Response(status_code=404)
with get_db_conn() as conn: with get_db_conn() as conn:
with conn: with conn:
@ -205,6 +279,19 @@ async def library_cover_cached(filename: str):
@router.get("/library/cover/{filename:path}") @router.get("/library/cover/{filename:path}")
async def library_cover(filename: str): async def library_cover(filename: str):
if is_db_filename(filename):
# DB books: cover is always served from the cache
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT thumb_webp FROM library_cover_cache WHERE filename = %s",
(filename,),
)
row = cur.fetchone()
if row and row[0]:
return Response(content=bytes(row[0]), media_type="image/webp")
return Response(status_code=404)
full = resolve_library_path(filename) full = resolve_library_path(filename)
if full is None or not full.exists(): if full is None or not full.exists():
return Response(status_code=404) return Response(status_code=404)
@ -213,10 +300,30 @@ async def library_cover(filename: str):
if mt == "epub": if mt == "epub":
from routers.common import extract_cover_from_epub from routers.common import extract_cover_from_epub
# Serve from cache when available (e.g. after a cover upload)
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT thumb_webp, mime_type FROM library_cover_cache WHERE filename = %s",
(filename,),
)
row = cur.fetchone()
if row and row[0]:
return Response(content=bytes(row[0]), media_type=row[1] or "image/webp")
# Fall back to extracting directly from the EPUB file
extracted = extract_cover_from_epub(full) extracted = extract_cover_from_epub(full)
if not extracted: if not extracted:
return Response(status_code=404) return Response(status_code=404)
raw, mime = extracted raw, mime = extracted
# Warm the cache for next time
try:
thumb = make_cover_thumb_webp(raw)
with get_db_conn() as conn:
with conn:
upsert_cover_cache(conn, filename, "image/webp", thumb)
except Exception:
pass
return Response(content=raw, media_type=mime) return Response(content=raw, media_type=mime)
if mt in {"pdf", "cbr"}: if mt in {"pdf", "cbr"}:
@ -269,7 +376,6 @@ async def library_add_cover(filename: str, request: Request):
upsert_cover_cache(conn, filename, "image/webp", thumb) upsert_cover_cache(conn, filename, "image/webp", thumb)
except (UnidentifiedImageError, OSError, ValueError): except (UnidentifiedImageError, OSError, ValueError):
pass pass
ensure_cover_missing_tag(conn, filename, True)
return {"ok": True} return {"ok": True}
@ -368,7 +474,7 @@ async def api_home():
l.series, l.series_index, l.publication_status, l.series, l.series_index, l.publication_status,
l.media_type, l.media_type,
COALESCE(rp.progress, 0) AS progress, COALESCE(rp.progress, 0) AS progress,
rp.cfi rp.cfi, l.rating
FROM reading_progress rp FROM reading_progress rp
JOIN library l ON l.filename = rp.filename JOIN library l ON l.filename = rp.filename
WHERE rp.progress > 0 WHERE rp.progress > 0
@ -380,7 +486,7 @@ async def api_home():
cur.execute( cur.execute(
""" """
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
FROM library l FROM library l
LEFT JOIN reading_sessions rs ON rs.filename = l.filename LEFT JOIN reading_sessions rs ON rs.filename = l.filename
LEFT JOIN reading_progress rp ON rp.filename = l.filename LEFT JOIN reading_progress rp ON rp.filename = l.filename
@ -396,7 +502,7 @@ async def api_home():
AND bt.tag = 'Shorts' AND bt.tag = 'Shorts'
AND bt.tag_type IN ('tag', 'subject') AND bt.tag_type IN ('tag', 'subject')
) )
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
ORDER BY RANDOM() ORDER BY RANDOM()
""" """
) )
@ -404,7 +510,7 @@ async def api_home():
cur.execute( cur.execute(
""" """
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
FROM library l FROM library l
LEFT JOIN reading_sessions rs ON rs.filename = l.filename LEFT JOIN reading_sessions rs ON rs.filename = l.filename
LEFT JOIN reading_progress rp ON rp.filename = l.filename LEFT JOIN reading_progress rp ON rp.filename = l.filename
@ -420,7 +526,7 @@ async def api_home():
AND bt.tag = 'Shorts' AND bt.tag = 'Shorts'
AND bt.tag_type IN ('tag', 'subject') AND bt.tag_type IN ('tag', 'subject')
) )
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
ORDER BY RANDOM() ORDER BY RANDOM()
""" """
) )
@ -429,7 +535,7 @@ async def api_home():
cur.execute( cur.execute(
""" """
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type,
MAX(rs.read_at) AS last_read MAX(rs.read_at) AS last_read, l.rating
FROM library l FROM library l
JOIN reading_sessions rs ON rs.filename = l.filename JOIN reading_sessions rs ON rs.filename = l.filename
WHERE COALESCE(l.series, '') = '' WHERE COALESCE(l.series, '') = ''
@ -442,7 +548,7 @@ async def api_home():
AND bt.tag = 'Shorts' AND bt.tag = 'Shorts'
AND bt.tag_type IN ('tag', 'subject') AND bt.tag_type IN ('tag', 'subject')
) )
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
ORDER BY MAX(rs.read_at) ASC ORDER BY MAX(rs.read_at) ASC
""" """
) )
@ -451,7 +557,7 @@ async def api_home():
cur.execute( cur.execute(
""" """
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type,
MAX(rs.read_at) AS last_read MAX(rs.read_at) AS last_read, l.rating
FROM library l FROM library l
JOIN reading_sessions rs ON rs.filename = l.filename JOIN reading_sessions rs ON rs.filename = l.filename
WHERE COALESCE(l.series, '') = '' WHERE COALESCE(l.series, '') = ''
@ -464,7 +570,7 @@ async def api_home():
AND bt.tag = 'Shorts' AND bt.tag = 'Shorts'
AND bt.tag_type IN ('tag', 'subject') AND bt.tag_type IN ('tag', 'subject')
) )
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating
ORDER BY MAX(rs.read_at) ASC ORDER BY MAX(rs.read_at) ASC
""" """
) )
@ -479,6 +585,7 @@ async def api_home():
"has_cover": bool(r[3]), "has_cover": bool(r[3]),
"publication_status": r[4] or "", "publication_status": r[4] or "",
"media_type": r[5] or "epub", "media_type": r[5] or "epub",
"rating": r[6] or 0,
"progress": 0, "progress": 0,
"series": "", "series": "",
"series_index": 0, "series_index": 0,
@ -496,6 +603,7 @@ async def api_home():
"publication_status": r[4] or "", "publication_status": r[4] or "",
"media_type": r[5] or "epub", "media_type": r[5] or "epub",
"last_read": r[6].isoformat() if r[6] else None, "last_read": r[6].isoformat() if r[6] else None,
"rating": r[7] or 0,
"progress": 0, "progress": 0,
"series": "", "series": "",
"series_index": 0, "series_index": 0,
@ -516,6 +624,7 @@ async def api_home():
"media_type": r[7] or "epub", "media_type": r[7] or "epub",
"progress": r[8] or 0, "progress": r[8] or 0,
"progress_cfi": r[9], "progress_cfi": r[9],
"rating": r[10] or 0,
} }
for r in cr_rows for r in cr_rows
], ],
@ -531,6 +640,73 @@ async def stats_page(request: Request):
return templates.TemplateResponse(request, "stats.html", {"active": "stats"}) return templates.TemplateResponse(request, "stats.html", {"active": "stats"})
@router.get("/api/disk")
async def api_disk():
usage = shutil.disk_usage(str(LIBRARY_DIR))
pct_used = round(usage.used / usage.total * 100, 1) if usage.total > 0 else 0
return {
"total": usage.total,
"used": usage.used,
"free": usage.free,
"pct_used": pct_used,
}
@router.post("/api/bulk-check-duplicates")
async def bulk_check_duplicates(request: Request):
body = await request.json()
items = body.get("items", [])
if not items or not isinstance(items, list):
return {"duplicates": []}
parsed = []
for item in items:
title = item.get("title", "").strip().lower()
author = item.get("author", "").strip().lower()
vol_str = item.get("volume", "").strip()
try:
vol_int = int(vol_str) if vol_str else None
except ValueError:
vol_int = None
parsed.append((title, author, vol_int))
# Fetch all DB rows matching any (title, author) pair
title_author_pairs = list({(t, a) for t, a, _ in parsed if t})
if not title_author_pairs:
return {"duplicates": [False] * len(items)}
conditions = " OR ".join(
"(LOWER(TRIM(title)) = %s AND LOWER(TRIM(author)) = %s)" for _ in title_author_pairs
)
params = [v for pair in title_author_pairs for v in pair]
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
f"SELECT LOWER(TRIM(title)), LOWER(TRIM(author)), series_index"
f" FROM library WHERE {conditions}",
params,
)
rows = cur.fetchall()
# (title, author, series_index) for volume-aware lookup
existing_with_vol = {(r[0] or "", r[1] or "", r[2]) for r in rows}
# (title, author) for volume-less lookup
existing_title_author = {(r[0] or "", r[1] or "") for r in rows}
duplicates = []
for title, author, vol_int in parsed:
if not title:
duplicates.append(False)
elif vol_int is not None:
# Volume known: only a duplicate when title+author+volume all match
duplicates.append((title, author, vol_int) in existing_with_vol)
else:
# No volume: duplicate if any title+author match exists
duplicates.append((title, author) in existing_title_author)
return {"duplicates": duplicates}
@router.get("/api/stats") @router.get("/api/stats")
async def api_stats(): async def api_stats():
with get_db_conn() as conn: with get_db_conn() as conn:
@ -691,5 +867,5 @@ async def api_stats():
@router.get("/library/list") @router.get("/library/list")
async def library_list_compat(): async def library_list_compat(request: Request):
return await api_library() return await api_library(request)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
"""search.py — Full-text search over DB-stored book chapters."""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from db import get_db_conn
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/search", response_class=HTMLResponse)
async def search_page(request: Request):
return templates.TemplateResponse(request, "search.html", {"active": "search"})
@router.get("/api/search")
async def api_search(q: str = ""):
q = q.strip()
if not q or len(q) > 500:
return JSONResponse([])
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
l.filename,
l.title,
l.author,
bc.chapter_index,
bc.title AS chapter_title,
ts_headline(
'simple', bc.content,
plainto_tsquery('simple', %s),
'MaxFragments=1, MaxWords=25, MinWords=8, StartSel=<mark>, StopSel=</mark>'
) AS snippet,
ts_rank(bc.content_tsv, plainto_tsquery('simple', %s)) AS rank
FROM book_chapters bc
JOIN library l ON l.filename = bc.filename
WHERE (bc.content_tsv @@ plainto_tsquery('simple', %s)
OR LOWER(bc.title) LIKE LOWER('%%' || %s || '%%'))
AND NOT l.archived
ORDER BY rank DESC, bc.chapter_index ASC
LIMIT 30
""",
(q, q, q, q),
)
rows = cur.fetchall()
results = [
{
"filename": r[0],
"title": r[1] or "",
"author": r[2] or "",
"chapter_index": r[3],
"chapter_title": r[4] or "",
"snippet": r[5] or "",
"rank": float(r[6]),
}
for r in rows
]
return JSONResponse(results)

View File

@ -1,9 +1,11 @@
from .base import BaseScraper from .base import BaseScraper
from .archiveofourown import ArchiveOfOurOwnScraper
from .awesomedude import AwesomeDudeScraper from .awesomedude import AwesomeDudeScraper
from .gayauthors import GayAuthorsScraper from .gayauthors import GayAuthorsScraper
# Register scrapers in priority order (first match wins) # Register scrapers in priority order (first match wins)
_SCRAPERS: list[type[BaseScraper]] = [ _SCRAPERS: list[type[BaseScraper]] = [
ArchiveOfOurOwnScraper,
AwesomeDudeScraper, AwesomeDudeScraper,
GayAuthorsScraper, GayAuthorsScraper,
] ]

View File

@ -0,0 +1,206 @@
import re
from urllib.parse import urljoin
import httpx
from bs4 import BeautifulSoup
from .base import BaseScraper
AO3_BASE = "https://archiveofourown.org"
class ArchiveOfOurOwnScraper(BaseScraper):
@classmethod
def matches(cls, url: str) -> bool:
return "archiveofourown.org" in url
def _work_base_url(self, url: str) -> str:
"""Strip chapter segment and query string; return /works/NNNNNN base URL."""
m = re.search(r"(https?://[^/]+/works/\d+)", url)
return m.group(1) if m else url.rstrip("/")
async def login(self, client: httpx.AsyncClient, username: str, password: str) -> bool:
r = await client.get(AO3_BASE + "/users/login")
soup = BeautifulSoup(r.text, "html.parser")
token_el = soup.find("input", {"name": "authenticity_token"})
token = token_el["value"] if token_el else ""
resp = await client.post(
AO3_BASE + "/users/login",
data={
"user[login]": username,
"user[password]": password,
"authenticity_token": token,
"commit": "Log in",
},
)
# Successful login redirects away from the login page
return "/users/login" not in str(resp.url)
async def fetch_book_info(self, client: httpx.AsyncClient, url: str) -> dict:
base_url = self._work_base_url(url)
r = await client.get(base_url, params={"view_adult": "true"})
soup = BeautifulSoup(r.text, "html.parser")
# Title
title_el = soup.find("h2", class_="title")
book_title = title_el.get_text(strip=True) if title_el else "Unknown title"
# Author — can be multiple; Anonymous if no author link
byline = soup.find("h3", class_="byline")
if byline:
author_links = byline.find_all("a", rel="author")
author = ", ".join(a.get_text(strip=True) for a in author_links) if author_links else "Anonymous"
else:
author = "Anonymous"
# Tags from dl.work.meta.group
meta_dl = soup.find("dl", class_="work")
def _tag_list(dl, css_class: str) -> list[str]:
dd = dl.find("dd", class_=css_class) if dl else None
return [a.get_text(strip=True) for a in dd.find_all("a")] if dd else []
fandoms = _tag_list(meta_dl, "fandom")
ratings = _tag_list(meta_dl, "rating")
categories = _tag_list(meta_dl, "category")
relationships = _tag_list(meta_dl, "relationship")
characters = _tag_list(meta_dl, "character")
freeform_tags = _tag_list(meta_dl, "freeform")
# Series
series = ""
series_index_hint = 0
if meta_dl:
series_dd = meta_dl.find("dd", class_="series")
if series_dd:
series_link = series_dd.find("a")
if series_link:
series = series_link.get_text(strip=True)
pos_span = series_dd.find("span", class_="position")
if pos_span:
m = re.search(r"Part\s+(\d+)", pos_span.get_text(), re.I)
if m:
series_index_hint = int(m.group(1))
# Stats (nested dl.stats inside the meta dl)
published = ""
updated_date = ""
publication_status = ""
if meta_dl:
stats_dl = meta_dl.find("dl", class_="stats")
if stats_dl:
pub_dd = stats_dl.find("dd", class_="published")
if pub_dd:
published = pub_dd.get_text(strip=True)
status_dt = stats_dl.find("dt", class_="status")
status_dd = stats_dl.find("dd", class_="status")
if status_dt and status_dd:
updated_date = status_dd.get_text(strip=True)
if "Completed" in status_dt.get_text():
publication_status = "Complete"
else:
publication_status = "Ongoing"
else:
# No status entry — determine from chapters count (N/N = complete)
updated_date = published
chapters_dd = stats_dl.find("dd", class_="chapters")
if chapters_dd:
m = re.match(r"(\d+)/(\d+|\?)", chapters_dd.get_text(strip=True))
if m:
if m.group(2) == "?":
publication_status = "Ongoing"
elif m.group(1) == m.group(2):
publication_status = "Complete"
# Summary
description = ""
summary_div = soup.find("div", class_="summary")
if summary_div:
userstuff = summary_div.find("blockquote", class_="userstuff")
if userstuff:
paras = [p.get_text(strip=True) for p in userstuff.find_all("p") if p.get_text().strip()]
description = "\n\n".join(paras) if paras else userstuff.get_text(strip=True)
# Chapter list via /navigate
chapter_links = []
chapter_method = "html_scan"
try:
nr = await client.get(base_url + "/navigate", params={"view_adult": "true"})
nsoup = BeautifulSoup(nr.text, "html.parser")
chapter_ol = nsoup.find("ol", class_="chapter")
if chapter_ol:
for li in chapter_ol.find_all("li"):
a = li.find("a", href=True)
if a:
chapter_links.append({
"url": urljoin(AO3_BASE, a["href"]),
"title": a.get_text(strip=True),
})
except Exception:
pass
# Fallback: single-chapter work — the work page itself is the content
if not chapter_links:
chapter_method = "fallback_numeric"
chapter_links.append({"url": base_url, "title": book_title})
# Map to Novela tag structure:
# fandoms → genres
# ratings + categories → subgenres
# relationships + characters + freeform → tags
return {
"title": book_title,
"author": author,
"publisher": "Archive of Our Own",
"series": series,
"series_index_hint": series_index_hint,
"genres": fandoms,
"subgenres": ratings + categories,
"tags": relationships + characters + freeform_tags,
"description": description,
"updated_date": updated_date,
"publication_status": publication_status,
"source_url": base_url,
"chapters": chapter_links,
"chapter_method": chapter_method,
}
async def fetch_chapter(self, client: httpx.AsyncClient, ch: dict) -> dict:
r = await client.get(ch["url"], params={"view_adult": "true"})
soup = BeautifulSoup(r.text, "html.parser")
# Chapter title and optional summary from the chapter preface
title = ch["title"]
chapter_summary_bq = None
chapters_div = soup.find("div", id="chapters")
if chapters_div:
chapter_div = chapters_div.find("div", class_="chapter")
if chapter_div:
title_el = chapter_div.find("h3", class_="title")
if title_el:
raw = title_el.get_text(strip=True)
if raw:
title = raw
summary_div = chapter_div.find("div", class_="summary")
if summary_div:
chapter_summary_bq = summary_div.find("blockquote", class_="userstuff")
# Content: div.userstuff inside #chapters (excludes author notes)
content_el = None
if chapters_div:
content_el = chapters_div.find("div", class_="userstuff")
if not content_el:
content_el = soup.find("div", attrs={"role": "article"})
# Prepend chapter summary as blockquote before story content
if chapter_summary_bq and content_el:
content_el.insert(0, chapter_summary_bq)
return {
"title": title,
"content_el": content_el,
"selector_id": content_el.get("id") if content_el else None,
"selector_class": " ".join(content_el.get("class", [])) if content_el else None,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -1,13 +1,5 @@
/* ── Novela — Book detail page styles ─────────────────────────────────── */ /* ── Novela — Book detail page styles ─────────────────────────────────── */
:root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a; --accent2: #e8a063;
--text: #e8e2d9; --text-dim: #8a8278; --text-faint: #4a453e;
--success: #6baa6b; --warning: #c8a03a; --error: #c85a3a;
--radius: 6px; --sidebar: 220px;
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); } html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
@ -36,6 +28,36 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
.cover-wrap img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } .cover-wrap img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.cover-wrap canvas { width: 100%; height: 100%; display: block; } .cover-wrap canvas { width: 100%; height: 100%; display: block; }
/* Star rating */
.star-row {
display: flex;
gap: 0.15rem;
margin-top: 0.6rem;
margin-bottom: 0.1rem;
justify-content: center;
}
.star {
font-size: 1.1rem;
color: rgba(200, 160, 58, 0.25);
cursor: default;
line-height: 1;
transition: color 0.1s;
user-select: none;
}
.star.filled {
color: #c8a03a;
}
.star-row.interactive .star {
cursor: pointer;
}
.star-row.interactive:hover .star {
color: #a07828;
}
/* Want to Read star under cover */ /* Want to Read star under cover */
.btn-wtr { .btn-wtr {
display: flex; align-items: center; gap: 0.5rem; display: flex; align-items: center; gap: 0.5rem;
@ -93,9 +115,10 @@ a.tag-pill:hover { color: var(--accent2); border-color: var(--accent); }
display: inline-block; font-family: var(--mono); font-size: 0.62rem; display: inline-block; font-family: var(--mono); font-size: 0.62rem;
padding: 0.15rem 0.5rem; border-radius: 3px; padding: 0.15rem 0.5rem; border-radius: 3px;
} }
.status-complete { background: rgba(107,170,107,0.12); color: var(--success); border: 1px solid rgba(107,170,107,0.25); } .status-complete { background: rgba(107,170,107,0.12); color: #6baa6b; border: 1px solid rgba(107,170,107,0.25); }
.status-ongoing { background: rgba(200,160,58,0.12); color: var(--warning); border: 1px solid rgba(200,160,58,0.25); } .status-ongoing { background: rgba(74,144,184,0.12); color: #4a90b8; border: 1px solid rgba(74,144,184,0.25); }
.status-hiatus { background: rgba(200,160,58,0.12); color: var(--warning); border: 1px solid rgba(200,160,58,0.25); } .status-temporary-hold { background: rgba(200,160,58,0.12); color: #c8a03a; border: 1px solid rgba(200,160,58,0.25); }
.status-long-term-hold { background: rgba(255,162,14,0.12); color: #ffa20e; border: 1px solid rgba(255,162,14,0.25); }
/* Progress */ /* Progress */
.progress-section { margin-bottom: 1.25rem; } .progress-section { margin-bottom: 1.25rem; }

View File

@ -1,5 +1,5 @@
/* ── Novela — Book detail page script ─────────────────────────────────── */ /* ── Novela — Book detail page script ─────────────────────────────────── */
/* Requires: BOOK global defined inline before this script is loaded */ /* Requires: books.js loaded first; BOOK global defined inline */
const { filename, title, author } = BOOK; const { filename, title, author } = BOOK;
@ -8,43 +8,6 @@ const { filename, title, author } = BOOK;
const canvas = document.getElementById('cover-canvas'); const canvas = document.getElementById('cover-canvas');
canvas.width = 180; canvas.width = 180;
canvas.height = 270; canvas.height = 270;
const COVER_PALETTES = [
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],['#2a1a2a','#8a4aaa'],
['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
];
function strHash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
function makePlaceholderCover(cv, ttl, auth) {
const w = cv.width || 180, h = cv.height || 270;
const ctx = cv.getContext('2d');
const [bg, fg] = COVER_PALETTES[strHash(ttl) % COVER_PALETTES.length];
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = fg; ctx.globalAlpha = 0.15; ctx.fillRect(0, 0, w, h * 0.08); ctx.globalAlpha = 1;
ctx.fillStyle = fg; ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
ctx.fillStyle = '#e8e2d9';
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
ctx.textAlign = 'center';
const words = ttl.split(' '); let line = '', lines = [];
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (ctx.measureText(test).width > w * 0.72 && line) { lines.push(line); line = word; }
else line = test;
}
if (line) lines.push(line);
lines = lines.slice(0, 4);
const lineH = Math.round(w * 0.12);
const startY = h * 0.28 - ((lines.length - 1) * lineH) / 2;
lines.forEach((l, i) => ctx.fillText(l, w * 0.55, startY + i * lineH));
ctx.fillStyle = fg; ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
ctx.globalAlpha = 0.85;
const a = auth.length > 18 ? auth.slice(0, 17) + '…' : auth;
ctx.fillText(a, w * 0.55, h * 0.86);
ctx.globalAlpha = 1;
}
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
if (BOOK.has_cover) { if (BOOK.has_cover) {
const img = document.getElementById('cover-img'); const img = document.getElementById('cover-img');
@ -52,6 +15,27 @@ if (BOOK.has_cover) {
else if (img) img.onload = () => canvas.style.display = 'none'; else if (img) img.onload = () => canvas.style.display = 'none';
} }
// ── Rating ─────────────────────────────────────────────────────────────────
let currentRating = BOOK.rating || 0;
async function rateBook(rating) {
const newRating = currentRating === rating ? 0 : rating;
try {
const resp = await fetch(`/library/rating/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: newRating }),
});
const result = await resp.json();
if (!resp.ok || result.error) return;
currentRating = result.rating;
document.querySelectorAll('#book-stars .star').forEach((el, idx) => {
el.classList.toggle('filled', idx + 1 <= currentRating);
});
} catch {}
}
// ── Want to Read toggle ──────────────────────────────────────────────────── // ── Want to Read toggle ────────────────────────────────────────────────────
async function toggleWtr() { async function toggleWtr() {
@ -187,6 +171,11 @@ class PillInput {
this._hideDropdown(); this._hideDropdown();
} }
flush() {
const v = this.input.value.trim();
if (v) this._add(v);
}
_showDropdown(items) { _showDropdown(items) {
if (!items.length) { this.dropdown.style.display = 'none'; return; } if (!items.length) { this.dropdown.style.display = 'none'; return; }
this.dropdown.innerHTML = items.map(g => this.dropdown.innerHTML = items.map(g =>
@ -221,7 +210,7 @@ class PillInput {
e.preventDefault(); e.preventDefault();
this.ddIndex = Math.max(this.ddIndex - 1, -1); this.ddIndex = Math.max(this.ddIndex - 1, -1);
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex)); opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
} else if (e.key === 'Enter') { } else if (e.key === 'Enter' || e.key === ',') {
e.preventDefault(); e.preventDefault();
if (this.ddIndex >= 0 && opts[this.ddIndex]) this._add(opts[this.ddIndex].dataset.val); if (this.ddIndex >= 0 && opts[this.ddIndex]) this._add(opts[this.ddIndex].dataset.val);
else if (this.input.value.trim()) this._add(this.input.value); else if (this.input.value.trim()) this._add(this.input.value);
@ -235,24 +224,89 @@ const genreInput = new PillInput('genre-box', 'genre-input', 'genre-dro
const subgenreInput = new PillInput('subgenre-box', 'subgenre-input', 'subgenre-dropdown'); const subgenreInput = new PillInput('subgenre-box', 'subgenre-input', 'subgenre-dropdown');
const tagInput = new PillInput('tag-box', 'tag-input', 'tag-dropdown'); const tagInput = new PillInput('tag-box', 'tag-input', 'tag-dropdown');
// ── TextSuggest — single-value autocomplete for plain text inputs ───────────
class TextSuggest {
constructor(inputId, dropdownId) {
this.input = document.getElementById(inputId);
this.dropdown = document.getElementById(dropdownId);
this.all = [];
this.ddIndex = -1;
this.input.addEventListener('input', () => this._onInput());
this.input.addEventListener('keydown', (e) => this._onKeydown(e));
this.input.addEventListener('blur', () => setTimeout(() => this._hide(), 150));
}
setSuggestions(all) { this.all = all; }
_show(items) {
if (!items.length) { this._hide(); return; }
this.dropdown.innerHTML = items.map(v =>
`<div class="genre-option" data-val="${v.replace(/"/g,'&quot;')}">${v}</div>`
).join('');
this.dropdown.querySelectorAll('.genre-option').forEach(el => {
el.onmousedown = (e) => { e.preventDefault(); this.input.value = el.dataset.val; this._hide(); };
});
this.dropdown.style.display = 'block';
this.ddIndex = -1;
}
_hide() { this.dropdown.style.display = 'none'; this.ddIndex = -1; }
_onInput() {
const q = this.input.value.trim().toLowerCase();
if (!q) { this._hide(); return; }
this._show(this.all.filter(v => v.toLowerCase().includes(q)));
}
_onKeydown(e) {
const opts = this.dropdown.querySelectorAll('.genre-option');
if (e.key === 'ArrowDown') {
e.preventDefault();
this.ddIndex = Math.min(this.ddIndex + 1, opts.length - 1);
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.ddIndex = Math.max(this.ddIndex - 1, -1);
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
} else if (e.key === 'Enter' && this.ddIndex >= 0 && opts[this.ddIndex]) {
e.preventDefault();
this.input.value = opts[this.ddIndex].dataset.val;
this._hide();
} else if (e.key === 'Escape') {
this._hide();
}
}
}
const authorSuggest = new TextSuggest('ed-author', 'author-dropdown');
const publisherSuggest = new TextSuggest('ed-publisher', 'publisher-dropdown');
const seriesSuggest = new TextSuggest('ed-series', 'series-dropdown');
// ── Edit panel ───────────────────────────────────────────────────────────── // ── Edit panel ─────────────────────────────────────────────────────────────
async function openEdit() { async function openEdit() {
const [allGenres, allSubgenres, allTags] = await Promise.all([ const [allGenres, allSubgenres, allTags, allAuthors, allPublishers, allSeries] = await Promise.all([
fetch('/api/genres?type=genre').then(r => r.json()), fetch('/api/genres?type=genre').then(r => r.json()),
fetch('/api/genres?type=subgenre').then(r => r.json()), fetch('/api/genres?type=subgenre').then(r => r.json()),
fetch('/api/genres?type=tag').then(r => r.json()), fetch('/api/genres?type=tag').then(r => r.json()),
fetch('/api/suggestions?type=author').then(r => r.json()),
fetch('/api/suggestions?type=publisher').then(r => r.json()),
fetch('/api/suggestions?type=series').then(r => r.json()),
]); ]);
genreInput.setSuggestions(allGenres); genreInput.setSuggestions(allGenres);
subgenreInput.setSuggestions(allSubgenres); subgenreInput.setSuggestions(allSubgenres);
tagInput.setSuggestions(allTags); tagInput.setSuggestions(allTags);
authorSuggest.setSuggestions(allAuthors);
publisherSuggest.setSuggestions(allPublishers);
seriesSuggest.setSuggestions(allSeries);
document.getElementById('ed-title').value = BOOK.title; document.getElementById('ed-title').value = BOOK.title;
document.getElementById('ed-author').value = BOOK.author; document.getElementById('ed-author').value = BOOK.author;
document.getElementById('ed-publisher').value = BOOK.publisher; document.getElementById('ed-publisher').value = BOOK.publisher;
document.getElementById('ed-series').value = BOOK.series; document.getElementById('ed-series').value = BOOK.series;
document.getElementById('ed-series-index').value = BOOK.series_index; document.getElementById('ed-series-index').value = BOOK.series_index + (BOOK.series_suffix || '');
document.getElementById('ed-status').value = BOOK.publication_status; document.getElementById('ed-status').value = BOOK.publication_status || 'Complete';
document.getElementById('ed-url').value = BOOK.source_url; document.getElementById('ed-url').value = BOOK.source_url;
document.getElementById('ed-publish-date').value = BOOK.publish_date; document.getElementById('ed-publish-date').value = BOOK.publish_date;
document.getElementById('ed-description').value = BOOK.description; document.getElementById('ed-description').value = BOOK.description;
@ -274,6 +328,9 @@ function closeEdit() {
} }
async function saveEdit() { async function saveEdit() {
genreInput.flush();
subgenreInput.flush();
tagInput.flush();
const body = { const body = {
title: document.getElementById('ed-title').value, title: document.getElementById('ed-title').value,
author: document.getElementById('ed-author').value, author: document.getElementById('ed-author').value,

View File

@ -0,0 +1,127 @@
// ── Novela — shared utilities ────────────────────────────────────────────────
// HTML-escape a string for safe insertion into markup.
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Placeholder cover generation ─────────────────────────────────────────────
function strHash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
const COVER_PALETTES = [
['#1a2a3a', '#4a8caa'],
['#2a1a1a', '#aa4a4a'],
['#1a2a1a', '#4aaa6a'],
['#2a1a2a', '#8a4aaa'],
['#2a2a1a', '#aaa04a'],
['#1a2a2a', '#4aaa9a'],
['#2a1a14', '#ffa20e'],
['#141a2a', '#5a78c8'],
];
function wrapText(ctx, text, x, y, maxW, lineH) {
const words = text.split(' ');
let line = '';
let lines = [];
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = word; }
else line = test;
}
if (line) lines.push(line);
lines = lines.slice(0, 4);
const startY = y - ((lines.length - 1) * lineH) / 2;
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
}
function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function makePlaceholderCover(canvas, title, author) {
const w = canvas.width = canvas.offsetWidth || 150;
const h = canvas.height = canvas.offsetHeight || 225;
const ctx = canvas.getContext('2d');
const [bg, fg] = COVER_PALETTES[strHash(title) % COVER_PALETTES.length];
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = fg;
ctx.globalAlpha = 0.15;
ctx.fillRect(0, 0, w, h * 0.08);
ctx.globalAlpha = 1;
ctx.fillStyle = fg;
ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
ctx.fillStyle = '#e8e2d9';
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
ctx.textAlign = 'center';
wrapText(ctx, title, w * 0.55, h * 0.28, w * 0.72, Math.round(w * 0.12));
ctx.fillStyle = fg;
ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
ctx.globalAlpha = 0.85;
ctx.fillText(truncate(author, 18), w * 0.55, h * 0.86);
ctx.globalAlpha = 1;
}
// ── Shared book helpers ───────────────────────────────────────────────────────
function _filenameBase(filename) {
const leaf = String(filename || '').split('/').pop() || '';
return leaf.replace(/\.[^.]+$/, '');
}
function bookTitle(b) {
return b.title || (_filenameBase(b.filename).split('-')[2] ?? '').replace(/_/g, ' ');
}
function bookAuthor(b) {
if (b.author) return b.author;
return (_filenameBase(b.filename).split('-')[1] ?? '').replace(/_/g, ' ');
}
function tagValuesByType(b, type) {
return (b.tags || []).filter(t => t && t.tag_type === type && t.tag).map(t => t.tag);
}
function bookGenres(b) {
const explicit = tagValuesByType(b, 'genre');
return explicit.length ? explicit : tagValuesByType(b, 'subject');
}
function bookSubgenres(b) { return tagValuesByType(b, 'subgenre'); }
function bookPlainTags(b) { return tagValuesByType(b, 'tag'); }
// Filter a list of books by a free-text query across all searchable fields.
function filterBooks(books, query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return books;
return books.filter(b =>
bookTitle(b).toLowerCase().includes(q) ||
bookAuthor(b).toLowerCase().includes(q) ||
(b.publisher || '').toLowerCase().includes(q) ||
bookGenres(b).some(g => g.toLowerCase().includes(q)) ||
bookSubgenres(b).some(g => g.toLowerCase().includes(q)) ||
bookPlainTags(b).some(g => g.toLowerCase().includes(q))
);
}
// Wire up a search input: show/hide clear button on input, trigger onSearch(query) on Enter.
function setupSearchInput(inputId, clearId, onSearch) {
const input = document.getElementById(inputId);
const clear = document.getElementById(clearId);
if (!input) return;
input.addEventListener('input', () => {
if (clear) clear.style.display = input.value.trim() ? '' : 'none';
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); onSearch(input.value.trim()); }
});
}

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

@ -0,0 +1,89 @@
// ── Shared SSE conversion stream handler ─────────────────────────────────────
// Requires: books.js (for esc())
function addLog(msg, cls) {
const div = document.getElementById('log-lines');
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = msg;
span.style.display = 'block';
div.appendChild(span);
div.scrollTop = div.scrollHeight;
}
function connectConversionStream(job_id) {
const es = new EventSource(`/events/${job_id}`);
es.addEventListener('status', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = d.message;
addLog(d.message);
});
es.addEventListener('meta', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = `"${d.title}" by ${d.author}`;
});
es.addEventListener('chapters', e => {
const d = JSON.parse(e.data);
const ul = document.getElementById('chapter-list');
ul.innerHTML = '';
d.chapters.forEach((title, i) => {
const li = document.createElement('li');
li.className = 'chapter-item';
li.id = `ch-${i}`;
li.innerHTML = `<span class="dot"></span><span>${esc(title)}</span>`;
ul.appendChild(li);
});
});
es.addEventListener('progress', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width =
Math.round((d.current / d.total) * 100) + '%';
document.getElementById('status-line').textContent =
`Chapter ${d.current} of ${d.total}: ${d.title}`;
if (d.current > 1) {
const prev = document.getElementById(`ch-${d.current - 2}`);
if (prev) prev.className = 'chapter-item done';
}
const cur = document.getElementById(`ch-${d.current - 1}`);
if (cur) { cur.className = 'chapter-item active'; cur.scrollIntoView({ block: 'nearest' }); }
});
es.addEventListener('warning', e => {
addLog(JSON.parse(e.data).message, 'warn');
});
es.addEventListener('error', e => {
const d = JSON.parse(e.data);
addLog(d.message, 'err');
document.getElementById('status-line').textContent = '❌ ' + d.message;
document.getElementById('convert-btn').disabled = false;
es.close();
});
es.addEventListener('done', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width = '100%';
document.getElementById('status-line').textContent = 'Done ✓';
document.querySelectorAll('.chapter-item').forEach(el => el.className = 'chapter-item done');
document.getElementById('result-meta').innerHTML =
`<strong>${esc(d.title)}</strong><br/>${d.chapters} chapters successfully converted`;
const dlBtn = document.getElementById('download-btn');
if (d.storage_type === 'db') {
dlBtn.querySelector('span') && (dlBtn.querySelector('span').textContent = 'Export EPUB');
dlBtn.onclick = () => { window.location = `/api/library/export-epub/${encodeURIComponent(d.filename)}`; };
} else {
dlBtn.querySelector('span') && (dlBtn.querySelector('span').textContent = 'Download EPUB');
dlBtn.onclick = () => { window.location = `/download/${encodeURIComponent(d.filename)}`; };
}
document.getElementById('book-detail-btn').onclick = () => {
window.location = `/library/book/${encodeURIComponent(d.filename)}`;
};
document.getElementById('result-card').classList.add('visible');
document.getElementById('convert-btn').disabled = false;
es.close();
});
}

View File

@ -1,10 +1,5 @@
:root { :root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b; --danger: #c85a5a;
--border: #2e2a24; --accent: #c8783a; --text: #e8e2d9;
--text-dim: #8a8278; --text-faint: #4a453e;
--success: #6baa6b; --danger: #c85a5a;
--radius: 6px;
--mono: 'DM Mono', monospace;
--header-h: 50px; --header-h: 50px;
--panel-w: 240px; --panel-w: 240px;
} }
@ -35,6 +30,13 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.chapter-title-input {
flex: 1; font-size: 0.72rem; font-family: var(--mono); color: var(--text);
background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 0.25rem 0.5rem; outline: none; min-width: 0;
}
.chapter-title-input:focus { border-color: var(--accent); }
.header-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; } .header-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.save-status { .save-status {
@ -62,7 +64,7 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
font-family: var(--mono); font-size: 0.72rem; color: var(--accent); font-family: var(--mono); font-size: 0.72rem; color: var(--accent);
cursor: pointer; transition: background 0.12s; cursor: pointer; transition: background 0.12s;
} }
.btn-save-all:hover { background: rgba(200,120,58,0.12); } .btn-save-all:hover { background: rgba(255,162,14,0.12); }
.btn-break { .btn-break {
display: flex; align-items: center; gap: 0.35rem; display: flex; align-items: center; gap: 0.35rem;

View File

@ -1,20 +1,26 @@
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } }); require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
const { filename } = EDITOR; const { filename, is_db } = EDITOR;
let editor = null; let editor = null;
let chapters = []; let chapters = [];
let currentIndex = -1; let currentIndex = -1;
let dirty = new Set(); // indices with unsaved changes let dirty = new Set(); // indices with unsaved changes
let pendingContent = new Map(); // index -> modified content not yet saved let pendingContent = new Map(); // index -> modified content not yet saved
let pendingTitles = new Map(); // index -> modified title not yet saved (DB only)
let loadingChapter = false; // suppress dirty events during setValue let loadingChapter = false; // suppress dirty events during setValue
let saving = false; let saving = false;
// ── Init Monaco ─────────────────────────────────────────────────────────────── // ── Init Monaco ───────────────────────────────────────────────────────────────
require(['vs/editor/editor.main'], function () { require(['vs/editor/editor.main'], function () {
if (is_db) {
document.getElementById('header-chapter').style.display = 'none';
document.getElementById('chapter-title-input').style.display = '';
}
editor = monaco.editor.create(document.getElementById('editor-pane'), { editor = monaco.editor.create(document.getElementById('editor-pane'), {
language: 'xml', language: is_db ? 'html' : 'xml',
theme: 'vs-dark', theme: 'vs-dark',
wordWrap: 'on', wordWrap: 'on',
minimap: { enabled: true }, minimap: { enabled: true },
@ -39,6 +45,19 @@ require(['vs/editor/editor.main'], function () {
// Ctrl+S / Cmd+S // Ctrl+S / Cmd+S
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveChapter); editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveChapter);
if (is_db) {
document.getElementById('chapter-title-input').addEventListener('input', () => {
if (currentIndex >= 0) {
pendingTitles.set(currentIndex, document.getElementById('chapter-title-input').value);
dirty.add(currentIndex);
renderChapterList();
setStatus('dirty', 'Unsaved changes');
document.getElementById('btn-save').disabled = false;
updateSaveAll();
}
});
}
loadChapterList(); loadChapterList();
}); });
@ -57,6 +76,7 @@ async function loadChapterList(targetIndex = 0) {
currentIndex = -1; currentIndex = -1;
dirty.clear(); dirty.clear();
pendingContent.clear(); pendingContent.clear();
pendingTitles.clear();
renderChapterList(); renderChapterList();
document.getElementById('header-chapter').textContent = 'No chapters'; document.getElementById('header-chapter').textContent = 'No chapters';
document.getElementById('btn-save').disabled = true; document.getElementById('btn-save').disabled = true;
@ -94,6 +114,11 @@ async function switchChapter(index) {
if (dirty.has(currentIndex) && editor) { if (dirty.has(currentIndex) && editor) {
pendingContent.set(currentIndex, editor.getValue()); pendingContent.set(currentIndex, editor.getValue());
} }
// Preserve title input for DB books
if (is_db && currentIndex >= 0) {
const inp = document.getElementById('chapter-title-input');
if (inp) pendingTitles.set(currentIndex, inp.value);
}
loadChapter(index); loadChapter(index);
} }
@ -102,19 +127,19 @@ async function loadChapter(index) {
document.getElementById('btn-save').disabled = true; document.getElementById('btn-save').disabled = true;
document.getElementById('btn-break').disabled = true; document.getElementById('btn-break').disabled = true;
document.getElementById('btn-del-page').disabled = true; document.getElementById('btn-del-page').disabled = true;
document.getElementById('header-chapter').textContent = 'Loading…'; if (!is_db) document.getElementById('header-chapter').textContent = 'Loading…';
let content, title; let content, title;
if (pendingContent.has(index)) { if (pendingContent.has(index)) {
content = pendingContent.get(index); content = pendingContent.get(index);
title = chapters[index]?.title ?? ''; title = pendingTitles.has(index) ? pendingTitles.get(index) : (chapters[index]?.title ?? '');
} else { } else {
const resp = await fetch(`/api/edit/chapter/${index}/${encodeURIComponent(filename)}`); const resp = await fetch(`/api/edit/chapter/${index}/${encodeURIComponent(filename)}`);
if (!resp.ok) { setStatus('error', 'Load failed'); return; } if (!resp.ok) { setStatus('error', 'Load failed'); return; }
const data = await resp.json(); const data = await resp.json();
content = data.content; content = data.content;
title = data.title; title = pendingTitles.has(index) ? pendingTitles.get(index) : data.title;
} }
currentIndex = index; currentIndex = index;
@ -123,6 +148,7 @@ async function loadChapter(index) {
editor.setValue(content); editor.setValue(content);
editor.setScrollTop(0); editor.setScrollTop(0);
loadingChapter = false; loadingChapter = false;
editor.focus();
// Restore dirty state based on whether we loaded from pending cache // Restore dirty state based on whether we loaded from pending cache
if (dirty.has(index)) { if (dirty.has(index)) {
@ -134,7 +160,11 @@ async function loadChapter(index) {
} }
renderChapterList(); renderChapterList();
document.getElementById('header-chapter').textContent = title; if (is_db) {
document.getElementById('chapter-title-input').value = title;
} else {
document.getElementById('header-chapter').textContent = title;
}
document.getElementById('btn-break').disabled = false; document.getElementById('btn-break').disabled = false;
document.getElementById('btn-del-page').disabled = chapters.length <= 1; document.getElementById('btn-del-page').disabled = chapters.length <= 1;
updateSaveAll(); updateSaveAll();
@ -149,18 +179,28 @@ async function saveChapter() {
setStatus('saving', 'Saving…'); setStatus('saving', 'Saving…');
try { try {
const saveBody = { content: editor.getValue() };
if (is_db) {
const inp = document.getElementById('chapter-title-input');
saveBody.title = inp ? inp.value.trim() : (pendingTitles.get(currentIndex) || '');
}
const resp = await fetch( const resp = await fetch(
`/api/edit/chapter/${currentIndex}/${encodeURIComponent(filename)}`, `/api/edit/chapter/${currentIndex}/${encodeURIComponent(filename)}`,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editor.getValue() }), body: JSON.stringify(saveBody),
} }
); );
const data = await resp.json(); const data = await resp.json();
if (data.ok) { if (data.ok) {
dirty.delete(currentIndex); dirty.delete(currentIndex);
pendingContent.delete(currentIndex); pendingContent.delete(currentIndex);
if (is_db && chapters[currentIndex]) {
const saved = pendingTitles.get(currentIndex) || chapters[currentIndex].title;
chapters[currentIndex].title = saved || chapters[currentIndex].title;
pendingTitles.delete(currentIndex);
}
renderChapterList(); renderChapterList();
setStatus('saved', 'Saved'); setStatus('saved', 'Saved');
setTimeout(() => setStatus('', ''), 2000); setTimeout(() => setStatus('', ''), 2000);
@ -186,9 +226,13 @@ async function saveAllChapters() {
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
setStatus('saving', 'Saving all…'); setStatus('saving', 'Saving all…');
// Flush current editor content into pendingContent first // Flush current editor content and title into pending caches first
if (currentIndex >= 0 && dirty.has(currentIndex)) { if (currentIndex >= 0 && dirty.has(currentIndex)) {
pendingContent.set(currentIndex, editor.getValue()); pendingContent.set(currentIndex, editor.getValue());
if (is_db) {
const inp = document.getElementById('chapter-title-input');
if (inp) pendingTitles.set(currentIndex, inp.value);
}
} }
const indices = [...dirty]; const indices = [...dirty];
@ -196,21 +240,29 @@ async function saveAllChapters() {
const content = pendingContent.has(i) const content = pendingContent.has(i)
? pendingContent.get(i) ? pendingContent.get(i)
: (i === currentIndex ? editor.getValue() : null); : (i === currentIndex ? editor.getValue() : null);
if (!content) continue; // For DB books, a title-only change has no pendingContent — still need to save
const hasTitleChange = is_db && pendingTitles.has(i);
if (!content && !hasTitleChange) continue;
try { try {
const saveBody = { content: content || '' };
if (is_db) saveBody.title = pendingTitles.has(i) ? pendingTitles.get(i) : (chapters[i]?.title || '');
const resp = await fetch( const resp = await fetch(
`/api/edit/chapter/${i}/${encodeURIComponent(filename)}`, `/api/edit/chapter/${i}/${encodeURIComponent(filename)}`,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }), body: JSON.stringify(saveBody),
} }
); );
const data = await resp.json(); const data = await resp.json();
if (data.ok) { if (data.ok) {
dirty.delete(i); dirty.delete(i);
pendingContent.delete(i); pendingContent.delete(i);
if (is_db && chapters[i]) {
chapters[i].title = pendingTitles.get(i) || chapters[i].title;
pendingTitles.delete(i);
}
} }
} catch { } catch {
setStatus('error', `Save failed on chapter ${i + 1}`); setStatus('error', `Save failed on chapter ${i + 1}`);
@ -251,10 +303,11 @@ function updateSaveAll() {
function insertBreak() { function insertBreak() {
if (!editor || currentIndex < 0) return; if (!editor || currentIndex < 0) return;
const breakSrc = is_db ? '/static/break.png' : '../Images/break.png';
const pos = editor.getPosition(); const pos = editor.getPosition();
editor.executeEdits('insert-break', [{ editor.executeEdits('insert-break', [{
range: new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column), range: new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column),
text: '\n<center><img src="../Images/break.png" style="height:15px;"/></center>\n', text: `\n<center><img src="${breakSrc}" style="height:15px;"/></center>\n`,
forceMoveMarkers: true, forceMoveMarkers: true,
}]); }]);
editor.focus(); editor.focus();
@ -286,6 +339,7 @@ async function addChapter() {
dirty.clear(); dirty.clear();
pendingContent.clear(); pendingContent.clear();
pendingTitles.clear();
await loadChapterList(data.index ?? Math.max(currentIndex + 1, 0)); await loadChapterList(data.index ?? Math.max(currentIndex + 1, 0));
setStatus('saved', 'Page added'); setStatus('saved', 'Page added');
setTimeout(() => setStatus('', ''), 1500); setTimeout(() => setStatus('', ''), 1500);
@ -315,6 +369,7 @@ async function deleteChapter() {
dirty.clear(); dirty.clear();
pendingContent.clear(); pendingContent.clear();
pendingTitles.clear();
await loadChapterList(data.index ?? Math.max(currentIndex - 1, 0)); await loadChapterList(data.index ?? Math.max(currentIndex - 1, 0));
setStatus('saved', 'Page deleted'); setStatus('saved', 'Page deleted');
setTimeout(() => setStatus('', ''), 1500); setTimeout(() => setStatus('', ''), 1500);
@ -424,7 +479,3 @@ function setStatus(cls, text) {
el.className = 'save-status' + (cls ? ' ' + cls : ''); el.className = 'save-status' + (cls ? ' ' + cls : '');
el.textContent = text; el.textContent = text;
} }
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

View File

@ -1,24 +1,5 @@
/* ── Novela — Library page styles ─────────────────────────────────────── */ /* ── Novela — Library page styles ─────────────────────────────────────── */
:root {
--bg: #0f0e0c;
--surface: #1a1815;
--surface2: #221f1b;
--border: #2e2a24;
--accent: #c8783a;
--accent2: #e8a063;
--text: #e8e2d9;
--text-dim: #8a8278;
--text-faint: #4a453e;
--success: #6baa6b;
--warning: #c8a03a;
--error: #c85a3a;
--radius: 6px;
--sidebar: 220px;
--mono: 'DM Mono', monospace;
--serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { html, body {
@ -59,6 +40,18 @@ html, body {
padding: 4rem 2rem; padding: 4rem 2rem;
} }
.group-heading {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
padding: 1.5rem 0 0.5rem;
border-bottom: 1px solid var(--border);
margin-bottom: 0.75rem;
}
.group-heading:first-child { padding-top: 0; }
.import-dropzone { .import-dropzone {
border: 1px dashed var(--border); border: 1px dashed var(--border);
background: rgba(34, 31, 27, 0.45); background: rgba(34, 31, 27, 0.45);
@ -71,7 +64,7 @@ html, body {
.import-dropzone:hover { border-color: var(--accent); } .import-dropzone:hover { border-color: var(--accent); }
.import-dropzone.dragover { .import-dropzone.dragover {
border-color: var(--accent2); border-color: var(--accent2);
background: rgba(200, 120, 58, 0.12); background: rgba(255, 162, 14, 0.12);
} }
.import-dropzone.uploading { .import-dropzone.uploading {
opacity: 0.8; opacity: 0.8;
@ -139,10 +132,13 @@ html, body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 2; z-index: 2;
background: rgba(15,14,12,0.82);
box-shadow: 0 0 0 2px #0f0e0c;
} }
.badge-complete { background: rgba(107,170,107,0.18); color: var(--success); } .badge-complete { color: #6baa6b; }
.badge-ongoing { background: rgba(200,160,58,0.18); color: var(--warning); } .badge-ongoing { color: #4a90b8; }
.badge-hiatus { background: rgba(200,160,58,0.18); color: var(--warning); } .badge-temporary-hold { color: #c8a03a; }
.badge-long-term-hold { color: #ffa20e; }
/* Star: want-to-read top-left */ /* Star: want-to-read top-left */
.btn-star { .btn-star {
@ -152,7 +148,7 @@ html, body {
width: 22px; width: 22px;
height: 22px; height: 22px;
border: none; border: none;
background: rgba(15,14,12,0.6); background: rgba(15,14,12,0.82);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@ -162,8 +158,9 @@ html, body {
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
padding: 0; padding: 0;
z-index: 2; z-index: 2;
box-shadow: 0 0 0 2px #0f0e0c;
} }
.btn-star:hover { color: var(--warning); background: rgba(15,14,12,0.8); } .btn-star:hover { color: var(--warning); background: rgba(15,14,12,0.82); }
.btn-star.starred { color: var(--warning); } .btn-star.starred { color: var(--warning); }
/* Book info below cover */ /* Book info below cover */
@ -201,7 +198,7 @@ html, body {
/* Read count pill */ /* Read count pill */
.read-pill { .read-pill {
position: absolute; bottom: 0.35rem; right: 0.35rem; position: absolute; bottom: 0.35rem; right: 0.35rem;
background: rgba(200,120,58,0.88); color: #0f0e0c; background: rgba(255,162,14,0.88); color: #0f0e0c;
font-family: var(--mono); font-size: 0.6rem; font-weight: 500; font-family: var(--mono); font-size: 0.6rem; font-weight: 500;
padding: 0.1rem 0.38rem; border-radius: 3px; z-index: 2; pointer-events: none; padding: 0.1rem 0.38rem; border-radius: 3px; z-index: 2; pointer-events: none;
} }
@ -210,7 +207,7 @@ html, body {
.progress-mini { .progress-mini {
position: absolute; bottom: 0; left: 0; right: 0; position: absolute; bottom: 0; left: 0; right: 0;
height: 3px; z-index: 2; pointer-events: none; height: 3px; z-index: 2; pointer-events: none;
background: rgba(200,120,58,0.25); background: rgba(255,162,14,0.25);
} }
.progress-mini-fill { height: 100%; background: var(--accent); } .progress-mini-fill { height: 100%; background: var(--accent); }
@ -436,13 +433,13 @@ html, body {
} }
.publisher-missing-wrap { .publisher-missing-wrap {
border: 1px solid rgba(200, 120, 58, 0.28); border: 1px solid rgba(255, 162, 14, 0.28);
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
} }
.publisher-missing-item { .publisher-missing-item {
background: rgba(200, 120, 58, 0.08); background: rgba(255, 162, 14, 0.08);
} }
.publisher-divider { .publisher-divider {
@ -455,6 +452,36 @@ html, body {
padding-top: 0.8rem; padding-top: 0.8rem;
} }
/* ── Star rating ────────────────────────────────────────────────────────── */
.star-row {
display: flex;
gap: 0.1rem;
margin-top: 0.3rem;
padding: 0 0.1rem;
}
.star {
font-size: 0.72rem;
color: rgba(200, 160, 58, 0.25);
cursor: default;
line-height: 1;
transition: color 0.1s;
user-select: none;
}
.star.filled {
color: var(--warning);
}
.star-row.interactive .star {
cursor: pointer;
}
.star-row.interactive:hover .star {
color: var(--accent2);
}
/* ── New view controls + list mode ─────────────────────────────────────── */ /* ── New view controls + list mode ─────────────────────────────────────── */
.new-controls { .new-controls {
@ -496,8 +523,8 @@ html, body {
} }
.btn.btn-view.active { .btn.btn-view.active {
border-color: rgba(200, 120, 58, 0.45); border-color: rgba(255, 162, 14, 0.45);
background: rgba(200, 120, 58, 0.16); background: rgba(255, 162, 14, 0.16);
color: var(--accent2); color: var(--accent2);
} }
@ -521,6 +548,21 @@ html, body {
cursor: not-allowed; cursor: not-allowed;
} }
.btn.btn-bulk-delete {
border: 1px solid rgba(200, 90, 58, 0.35);
background: rgba(200, 90, 58, 0.14);
color: var(--error);
}
.btn.btn-bulk-delete:hover {
background: rgba(200, 90, 58, 0.24);
}
.btn.btn-bulk-delete:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.new-selection-count { .new-selection-count {
font-family: var(--mono); font-family: var(--mono);
font-size: 0.68rem; font-size: 0.68rem;
@ -602,7 +644,7 @@ html, body {
} }
.new-list-table tbody tr:hover { .new-list-table tbody tr:hover {
background: rgba(200, 120, 58, 0.08); background: rgba(255, 162, 14, 0.08);
} }
.new-col-select { .new-col-select {
@ -637,3 +679,114 @@ html, body {
right: auto; right: auto;
} }
} }
/* ── Bookmark cards ─────────────────────────────────────────────────────── */
.bm-card {
display: flex;
gap: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.9rem 1rem;
margin-bottom: 0.75rem;
max-width: 720px;
}
.bm-card-cover {
flex-shrink: 0;
width: 60px; height: 90px;
border-radius: 3px;
overflow: hidden;
display: block;
background: var(--surface2);
}
.bm-card-cover img {
width: 100%; height: 100%;
object-fit: cover;
}
.bm-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.bm-card-book {
font-family: var(--serif);
font-size: 0.9rem;
color: var(--text);
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bm-card-author {
font-family: var(--mono);
font-size: 0.7rem;
color: var(--text-dim);
}
.bm-card-chapter {
font-family: var(--mono);
font-size: 0.72rem;
color: var(--accent);
margin-top: 0.15rem;
}
.bm-card-note {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 0.3rem;
white-space: pre-wrap;
line-height: 1.5;
}
.bm-card-meta {
font-family: var(--mono);
font-size: 0.65rem;
color: var(--text-faint);
margin-top: auto;
padding-top: 0.4rem;
}
.bm-card-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.btn-small {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.65rem;
border-radius: var(--radius);
font-family: var(--mono);
font-size: 0.68rem;
cursor: pointer;
border: 1px solid var(--border);
background: none;
color: var(--text-dim);
text-decoration: none;
transition: color 0.12s, border-color 0.12s;
}
.btn-small:hover {
color: var(--text);
border-color: var(--text-faint);
}
.btn-small.btn-danger {
color: var(--error);
border-color: rgba(200,90,58,0.3);
}
.btn-small.btn-danger:hover {
background: rgba(200,90,58,0.1);
border-color: var(--error);
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

View File

@ -24,6 +24,16 @@ html {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.sidebar-logo a {
display: flex;
align-items: center;
gap: 0.4rem;
}
.sidebar-logo-img {
width: 26px;
height: auto;
flex-shrink: 0;
}
.sidebar-logo h1 { .sidebar-logo h1 {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
@ -31,13 +41,6 @@ html {
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.sidebar-logo h1 span { color: var(--accent); } .sidebar-logo h1 span { color: var(--accent); }
.sidebar-logo p {
font-family: var(--mono);
font-size: 0.62rem;
color: var(--text-dim);
letter-spacing: 0.1em;
margin-top: 0.2rem;
}
.sidebar-section-label { .sidebar-section-label {
font-family: var(--mono); font-family: var(--mono);
@ -69,8 +72,10 @@ html {
text-decoration: none; text-decoration: none;
transition: background 0.12s, color 0.12s; transition: background 0.12s, color 0.12s;
} }
.sidebar-nav a:visited { color: var(--text-dim); }
.sidebar-nav a:hover { background: var(--surface2); color: var(--text); } .sidebar-nav a:hover { background: var(--surface2); color: var(--text); }
.sidebar-nav a.active { background: var(--surface2); color: var(--accent); } .sidebar-nav a.active,
.sidebar-nav a.active:visited { background: var(--surface2); color: var(--accent); }
.sidebar-nav a svg { flex-shrink: 0; } .sidebar-nav a svg { flex-shrink: 0; }
.sidebar-count { .sidebar-count {
@ -89,6 +94,25 @@ html {
.sidebar-bottom { margin-top: auto; } .sidebar-bottom { margin-top: auto; }
.disk-warning {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
border-radius: var(--radius);
font-family: var(--mono);
font-size: 0.7rem;
margin-bottom: 0.5rem;
background: rgba(200,160,58,0.12);
border: 1px solid rgba(200,160,58,0.3);
color: #c8a03a;
}
.disk-warning.critical {
background: rgba(200,90,58,0.12);
border-color: rgba(200,90,58,0.3);
color: #c85a3a;
}
.btn-rescan { .btn-rescan {
display: flex; display: flex;
align-items: center; align-items: center;
@ -109,6 +133,43 @@ html {
.btn-rescan:hover { background: var(--surface2); color: var(--text); } .btn-rescan:hover { background: var(--surface2); color: var(--text); }
.btn-rescan:disabled { opacity: 0.5; cursor: not-allowed; } .btn-rescan:disabled { opacity: 0.5; cursor: not-allowed; }
.backup-status-bar {
display: flex;
align-items: center;
gap: 0.45rem;
width: 100%;
padding: 0.35rem 0.6rem;
border-radius: var(--radius);
font-family: var(--mono);
font-size: 0.68rem;
color: var(--accent);
text-decoration: none;
transition: background 0.12s, opacity 0.12s;
margin-bottom: 0.25rem;
}
.backup-status-bar:hover { background: var(--surface2); opacity: 0.85; }
.backup-dot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--border);
}
.backup-dot.dot-ok { background: var(--ok, #7fbe7f); }
.backup-dot.dot-err { background: var(--err, #d0674c); }
.backup-dot.dot-dim { background: var(--text-dim); opacity: 0.5; }
.backup-dot.dot-running {
background: var(--warn, #d2b063);
animation: backup-pulse 1.2s ease-in-out infinite;
}
@keyframes backup-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.backup-status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Mobile hamburger ──────────────────────────────────────────────────── */ /* ── Mobile hamburger ──────────────────────────────────────────────────── */
.sidebar-toggle { .sidebar-toggle {

View File

@ -0,0 +1,20 @@
/* ── Novela — shared CSS custom properties ──────────────────────────────── */
:root {
--bg: #0f0e0c;
--surface: #1a1815;
--surface2: #221f1b;
--border: #2e2a24;
--accent: #ffa20e;
--accent2: #ffb840;
--text: #e8e2d9;
--text-dim: #8a8278;
--text-faint: #4a453e;
--success: #6baa6b;
--warning: #c8a03a;
--error: #c85a3a;
--radius: 6px;
--sidebar: 220px;
--mono: 'DM Mono', monospace;
--serif: 'Libre Baskerville', Georgia, serif;
}

View File

@ -9,7 +9,10 @@
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar-logo"> <div class="sidebar-logo">
<a href="/home" style="text-decoration:none;color:inherit"><h1>No<span>vela</span></h1></a> <a href="/home" style="text-decoration:none;color:inherit">
<img src="/static/logo.png" alt="N" class="sidebar-logo-img"/>
<h1>No<span>vela</span></h1>
</a>
</div> </div>
<ul class="sidebar-nav"> <ul class="sidebar-nav">
@ -60,6 +63,16 @@
<span class="sidebar-count" id="count-new"></span> <span class="sidebar-count" id="count-new"></span>
</a> </a>
</li> </li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#incomplete{% endif %}"
{% if active == 'library' %}id="nav-incomplete" onclick="switchView('incomplete'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
Incomplete
<span class="sidebar-count" id="count-incomplete"></span>
</a>
</li>
<li> <li>
<a href="{% if active == 'library' %}#{% else %}/library#series{% endif %}" <a href="{% if active == 'library' %}#{% else %}/library#series{% endif %}"
{% if active == 'library' %}id="nav-series" onclick="switchView('series'); return false;"{% endif %}> {% if active == 'library' %}id="nav-series" onclick="switchView('series'); return false;"{% endif %}>
@ -109,6 +122,44 @@
<span class="sidebar-count" id="count-archived"></span> <span class="sidebar-count" id="count-archived"></span>
</a> </a>
</li> </li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#bookmarks{% endif %}"
{% if active == 'library' %}id="nav-bookmarks" onclick="switchView('bookmarks'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
Bookmarks
<span class="sidebar-count" id="count-bookmarks"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#rated{% endif %}"
{% if active == 'library' %}id="nav-rated" onclick="switchView('rated'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
Rated
<span class="sidebar-count" id="count-rated"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#duplicates{% endif %}"
{% if active == 'library' %}id="nav-duplicates" onclick="switchView('duplicates'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
Duplicates
<span class="sidebar-count" id="count-duplicates"></span>
</a>
</li>
<li>
<a href="/search"{% if active == 'search' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
Search
</a>
</li>
<li> <li>
<a href="/stats"{% if active == 'stats' %} class="active"{% endif %}> <a href="/stats"{% if active == 'stats' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -123,6 +174,24 @@
<hr class="sidebar-divider"/> <hr class="sidebar-divider"/>
<div class="sidebar-section-label">Following</div>
<ul class="sidebar-nav">
<li>
<a href="/following"{% if active == 'following' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
<line x1="19" y1="8" x2="19" y2="14"/>
<line x1="22" y1="11" x2="16" y2="11"/>
</svg>
Authors
<span class="sidebar-count" id="count-following"></span>
</a>
</li>
</ul>
<hr class="sidebar-divider"/>
<div class="sidebar-section-label">Tools</div> <div class="sidebar-section-label">Tools</div>
<ul class="sidebar-nav"> <ul class="sidebar-nav">
<li> <li>
@ -133,6 +202,24 @@
Convert Convert
</a> </a>
</li> </li>
<li>
<a href="/builder"{% if active == 'builder' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
Book Builder
</a>
</li>
<li>
<a href="/bulk-import"{% if active == 'bulk_import' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Bulk Import
</a>
</li>
<li> <li>
<a href="/credentials-manager"{% if active == 'credentials' %} class="active"{% endif %}> <a href="/credentials-manager"{% if active == 'credentials' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -168,9 +255,29 @@
Settings Settings
</a> </a>
</li> </li>
<li>
<a href="/changelog"{% if active == 'changelog' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
Changelog
</a>
</li>
</ul> </ul>
<div class="sidebar-bottom"> <div class="sidebar-bottom">
<div class="disk-warning" id="disk-warning" style="display:none">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="disk-warning-text"></span>
</div>
<a href="/backup" class="backup-status-bar" id="backup-status-bar" title="Go to Backup">
<span class="backup-dot" id="backup-dot"></span>
<span class="backup-status-text" id="backup-status-text">Backup…</span>
</a>
<button class="btn-rescan" onclick="rescanLibraryGlobal()" id="rescan-btn"> <button class="btn-rescan" onclick="rescanLibraryGlobal()" id="rescan-btn">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="23 4 23 10 17 10"/> <polyline points="23 4 23 10 17 10"/>
@ -202,7 +309,18 @@
const seriesCount = new Set(active.filter(b => b.series).map(b => b.series)).size; const seriesCount = new Set(active.filter(b => b.series).map(b => b.series)).size;
const authorCount = new Set(active.map(b => b.author).filter(Boolean)).size; const authorCount = new Set(active.map(b => b.author).filter(Boolean)).size;
const publisherCount = new Set(active.map(b => b.publisher).filter(Boolean)).size; const publisherCount = new Set(active.map(b => b.publisher).filter(Boolean)).size;
const archivedCount = books.filter(b => b.archived).length; const archivedCount = books.filter(b => b.archived).length;
const ratedCount = active.filter(b => b.rating > 0).length;
const incompleteCount = active.filter(b => (b.publication_status || '').toLowerCase() !== 'complete').length;
const dupMap = new Map();
active.forEach(b => {
const key = (b.title || '').trim().toLowerCase() + '|' + (b.author || '').trim().toLowerCase();
dupMap.set(key, (dupMap.get(key) || 0) + 1);
});
const dupCount = active.filter(b => {
const key = (b.title || '').trim().toLowerCase() + '|' + (b.author || '').trim().toLowerCase();
return dupMap.get(key) >= 2;
}).length;
const setCount = (id, value) => { const setCount = (id, value) => {
const el = document.getElementById(id); const el = document.getElementById(id);
@ -215,7 +333,10 @@
setCount('count-series', seriesCount); setCount('count-series', seriesCount);
setCount('count-authors', authorCount); setCount('count-authors', authorCount);
setCount('count-publishers', publisherCount); setCount('count-publishers', publisherCount);
setCount('count-rated', ratedCount);
setCount('count-archived', archivedCount); setCount('count-archived', archivedCount);
setCount('count-duplicates', dupCount);
setCount('count-incomplete', incompleteCount);
} }
async function refreshLibraryCounts() { async function refreshLibraryCounts() {
@ -229,6 +350,79 @@
} }
} }
async function loadBackupStatus() {
const bar = document.getElementById('backup-status-bar');
const dot = document.getElementById('backup-dot');
const text = document.getElementById('backup-status-text');
if (!bar) return;
try {
const r = await fetch('/api/backup/status');
const d = await r.json();
const s = d.status || 'never';
dot.className = 'backup-dot';
if (s === 'running') {
dot.classList.add('dot-running');
text.textContent = 'Backup running…';
loadBackupProgress();
return;
} else if (s === 'success') {
dot.classList.add('dot-ok');
const ago = d.finished_at ? _backupAgo(d.finished_at) : '';
text.textContent = 'Backup OK' + (ago ? ' · ' + ago : '');
} else if (s === 'error') {
dot.classList.add('dot-err');
text.textContent = 'Backup failed';
} else {
dot.classList.add('dot-dim');
text.textContent = 'No backup yet';
}
} catch (_) {
const dot2 = document.getElementById('backup-dot');
if (dot2) dot2.className = 'backup-dot dot-dim';
if (text) text.textContent = 'Backup unavailable';
}
}
async function loadBackupProgress() {
const dot = document.getElementById('backup-dot');
const text = document.getElementById('backup-status-text');
if (!dot) return;
try {
const r = await fetch('/api/backup/progress');
const d = await r.json();
if (!d.running) {
// Backup finished; reload full status
await loadBackupStatus();
return;
}
dot.className = 'backup-dot dot-running';
const phase = d.phase || '';
const phaseLbl = phase === 'scanning' ? 'scanning' :
phase === 'snapshot' ? 'snapshot' :
phase === 'pg_dump' ? 'pg_dump' : 'uploading';
if (d.total > 0) {
text.textContent = `${d.done} / ${d.total} · ${phaseLbl}`;
} else {
text.textContent = `Backup · ${phaseLbl}`;
}
} catch (_) {
// Ignore; will retry
}
setTimeout(loadBackupProgress, 3000);
}
function _backupAgo(isoStr) {
try {
// Ensure the string is parsed as UTC (append Z if no timezone info present)
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';
const diff = Math.floor((Date.now() - new Date(s).getTime()) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
} catch (_) { return ''; }
}
async function rescanLibraryGlobal() { async function rescanLibraryGlobal() {
const btn = document.getElementById('rescan-btn'); const btn = document.getElementById('rescan-btn');
const label = document.getElementById('rescan-label'); const label = document.getElementById('rescan-label');
@ -246,5 +440,52 @@
} }
} }
async function refreshBookmarkCount() {
try {
const resp = await fetch('/api/bookmarks');
if (!resp.ok) return;
const bms = await resp.json();
const el = document.getElementById('count-bookmarks');
if (el) el.textContent = bms.length || '';
} catch (_) {}
}
async function refreshFollowingCount() {
try {
const resp = await fetch('/api/following');
if (!resp.ok) return;
const authors = await resp.json();
const el = document.getElementById('count-following');
if (el) el.textContent = authors.filter(a => a.url).length || '';
} catch (_) {}
}
async function checkDiskUsage() {
try {
const r = await fetch('/api/disk');
if (!r.ok) return;
const d = await r.json();
const el = document.getElementById('disk-warning');
const txt = document.getElementById('disk-warning-text');
if (!el || !txt) return;
const gb = d.free / (1024 ** 3);
const critical = d.pct_used >= 95 || gb < 0.5;
const warning = d.pct_used >= 85 || gb < 2;
if (critical || warning) {
const freeStr = gb < 1 ? (d.free / (1024 ** 2)).toFixed(0) + ' MB' : gb.toFixed(1) + ' GB';
txt.textContent = `Storage ${d.pct_used}% used · ${freeStr} free`;
el.className = 'disk-warning' + (critical ? ' critical' : '');
el.style.display = 'flex';
} else {
el.style.display = 'none';
}
} catch (_) {}
}
refreshLibraryCounts(); refreshLibraryCounts();
refreshBookmarkCount();
refreshFollowingCount();
loadBackupStatus();
checkDiskUsage();
setInterval(checkDiskUsage, 60_000);
</script> </script>

View File

@ -4,17 +4,17 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela - Backup</title> <title>Novela - Backup</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/> <link rel="stylesheet" href="/static/sidebar.css"/>
<style> <style>
:root { :root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a;
--text: #e8e2d9; --text-dim: #8a8278;
--ok: #7fbe7f; --warn: #d2b063; --err: #d0674c; --ok: #7fbe7f; --warn: #d2b063; --err: #d0674c;
--sidebar: 220px; --radius: 8px;
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); } html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
@ -87,7 +87,7 @@
cursor: pointer; cursor: pointer;
} }
.btn:hover { border-color: var(--accent); } .btn:hover { border-color: var(--accent); }
.btn.primary { border-color: rgba(200,120,58,0.45); background: rgba(200,120,58,0.12); } .btn.primary { border-color: rgba(255,162,14,0.45); background: rgba(255,162,14,0.12); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; } .btn:disabled { opacity: 0.5; cursor: not-allowed; }
.field-label { .field-label {
@ -135,25 +135,63 @@
<section class="card"> <section class="card">
<div class="card-head">Dropbox Settings</div> <div class="card-head">Dropbox Settings</div>
<label class="field-label" for="dropbox-token">Access Token</label>
<input class="field-input" id="dropbox-token" type="password" placeholder="sl.B..." autocomplete="off"/> <!-- Stap 1: App key + secret invullen en auth URL genereren -->
<label class="field-label" for="dropbox-root">Dropbox Root Path</label> <div id="oauth-step1">
<input class="field-input" id="dropbox-root" type="text" placeholder="/novela" autocomplete="off"/> <p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
<label class="field-label" for="retention-count">Snapshots To Keep</label> Enter your App Key and App Secret from the
<input class="field-input" id="retention-count" type="number" min="1" step="1" value="14" autocomplete="off"/> <a href="https://www.dropbox.com/developers/apps" target="_blank" style="color:var(--accent);">Dropbox Developer Console</a>.
<label class="field-label" for="schedule-enabled">Schedule Enabled</label> Then click <strong>Generate Auth URL</strong> and follow the instructions.
<select class="field-input" id="schedule-enabled"> </p>
<option value="false">Disabled</option> <label class="field-label" for="app-key">App Key</label>
<option value="true">Enabled</option> <input class="field-input" id="app-key" type="text" placeholder="fyh6wd677d54ger"
</select> autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other"/>
<label class="field-label" for="schedule-hours">Schedule Interval (hours)</label> <label class="field-label" for="app-secret">App Secret</label>
<input class="field-input" id="schedule-hours" type="number" min="1" step="1" value="24" autocomplete="off"/> <input class="field-input" id="app-secret" type="password" placeholder="App secret"
<div class="actions"> autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other"/>
<button class="btn primary" onclick="saveDropboxToken()">Save Token</button> <div class="actions">
<button class="btn" onclick="toggleDropboxToken()">Show / Hide</button> <button class="btn primary" onclick="oauthPrepare()">Generate Auth URL</button>
<button class="btn" onclick="clearDropboxToken()">Remove Token</button> </div>
<div class="status-line" id="oauth-step1-status"></div>
</div>
<!-- Stap 2: Auth URL tonen + code invoeren (initieel verborgen) -->
<div id="oauth-step2" style="display:none;margin-top:1rem;border-top:1px solid var(--border);padding-top:1rem;">
<p class="muted" style="margin-top:0;margin-bottom:0.5rem;">
Open the URL below in your browser, log in to Dropbox and approve the app.
Dropbox will then show a code — paste it below.
</p>
<div style="margin-bottom:0.7rem;">
<a id="oauth-auth-url" href="#" target="_blank" style="color:var(--accent);font-family:var(--mono);font-size:0.75rem;word-break:break-all;"></a>
</div>
<label class="field-label" for="oauth-code">Authorization Code</label>
<input class="field-input" id="oauth-code" type="text" placeholder="Paste the code from Dropbox here" autocomplete="off"/>
<div class="actions">
<button class="btn primary" onclick="oauthExchange()">Save &amp; Activate</button>
<button class="btn" onclick="oauthReset()">Cancel</button>
</div>
<div class="status-line" id="oauth-step2-status"></div>
</div>
<!-- Overige instellingen -->
<div style="margin-top:1rem;border-top:1px solid var(--border);padding-top:1rem;">
<label class="field-label" for="dropbox-root">Dropbox Root Path</label>
<input class="field-input" id="dropbox-root" type="text" placeholder="/novela" autocomplete="off"/>
<label class="field-label" for="retention-count">Snapshots To Keep</label>
<input class="field-input" id="retention-count" type="number" min="1" step="1" value="14" autocomplete="off"/>
<label class="field-label" for="schedule-enabled">Schedule Enabled</label>
<select class="field-input" id="schedule-enabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
<label class="field-label" for="schedule-hours">Schedule Interval (hours)</label>
<input class="field-input" id="schedule-hours" type="number" min="1" step="1" value="24" autocomplete="off"/>
<div class="actions">
<button class="btn primary" onclick="saveDropboxToken()">Save Settings</button>
<button class="btn" onclick="clearDropboxToken()">Remove Token</button>
</div>
<div class="status-line" id="dropbox-status"></div>
</div> </div>
<div class="status-line" id="dropbox-status"></div>
</section> </section>
<section class="card"> <section class="card">
@ -198,13 +236,47 @@
</table> </table>
</div> </div>
</section> </section>
<section class="card">
<div class="card-head">Restore</div>
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
Browse a snapshot and restore individual books from Dropbox back to disk.
</p>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;">
<select class="field-input" id="snapshot-select" style="flex:1;min-width:220px;margin:0;" onchange="onSnapshotChange()">
<option value="">— select snapshot —</option>
</select>
<button class="btn" onclick="loadSnapshots()">Refresh</button>
</div>
<div id="restore-file-panel" style="display:none;">
<input class="field-input" id="restore-search" type="text" placeholder="Filter by filename or path…" oninput="renderRestoreFiles()" style="margin-bottom:0.5rem;"/>
<div class="actions" style="margin-bottom:0.7rem;">
<button class="btn" onclick="selectAllRestoreFiles()">Select all</button>
<button class="btn" onclick="clearRestoreSelection()">Clear</button>
<button class="btn primary" id="btn-restore-selected" onclick="restoreSelected()" disabled>Restore selected</button>
</div>
<div style="overflow:auto;">
<table>
<thead>
<tr>
<th style="width:1.5rem;"></th>
<th style="width:3rem;">Format</th>
<th>Path</th>
<th style="width:5rem;">Size</th>
<th style="width:5rem;">On disk</th>
<th style="width:5rem;"></th>
</tr>
</thead>
<tbody id="restore-file-body"></tbody>
</table>
</div>
</div>
<div class="status-line" id="restore-status"></div>
</section>
</main> </main>
<script src="/static/books.js"></script>
<script> <script>
function esc(v) {
return String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function rowHtml(k, v) { function rowHtml(k, v) {
return `<div class="row"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div></div>`; return `<div class="row"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div></div>`;
} }
@ -277,7 +349,6 @@
async function loadDropboxSettings() { async function loadDropboxSettings() {
const out = document.getElementById('dropbox-status'); const out = document.getElementById('dropbox-status');
const tokenEl = document.getElementById('dropbox-token');
const rootEl = document.getElementById('dropbox-root'); const rootEl = document.getElementById('dropbox-root');
const retentionEl = document.getElementById('retention-count'); const retentionEl = document.getElementById('retention-count');
const scheduleEnabledEl = document.getElementById('schedule-enabled'); const scheduleEnabledEl = document.getElementById('schedule-enabled');
@ -287,14 +358,14 @@
try { try {
const r = await fetch('/api/backup/credentials'); const r = await fetch('/api/backup/credentials');
const d = await r.json(); const d = await r.json();
tokenEl.value = '';
rootEl.value = d.dropbox_root || '/novela'; rootEl.value = d.dropbox_root || '/novela';
retentionEl.value = d.retention_count ?? 14; retentionEl.value = d.retention_count ?? 14;
scheduleEnabledEl.value = String(!!d.schedule_enabled); scheduleEnabledEl.value = String(!!d.schedule_enabled);
scheduleHoursEl.value = d.schedule_interval_hours ?? 24; scheduleHoursEl.value = d.schedule_interval_hours ?? 24;
if (d.configured) { if (d.configured) {
out.className = 'status-line ok'; out.className = 'status-line ok';
out.textContent = `Configured (${d.token_preview || 'token set'})${d.updated_at ? ` • updated ${d.updated_at}` : ''}`; const mode = d.app_key_configured ? ' • refresh token' : ' • legacy token';
out.textContent = `Configured (${d.token_preview || 'token set'})${mode}${d.updated_at ? ` • updated ${d.updated_at}` : ''}`;
} else { } else {
out.className = 'status-line warn'; out.className = 'status-line warn';
out.textContent = 'No Dropbox token configured.'; out.textContent = 'No Dropbox token configured.';
@ -305,9 +376,75 @@
} }
} }
async function oauthPrepare() {
const out = document.getElementById('oauth-step1-status');
const appKey = (document.getElementById('app-key').value || '').trim();
const appSecret = (document.getElementById('app-secret').value || '').trim();
if (!appKey || !appSecret) {
out.className = 'status-line err';
out.textContent = 'Fill in both App Key and App Secret.';
return;
}
out.className = 'status-line warn';
out.textContent = 'Generating auth URL...';
try {
const r = await fetch('/api/backup/oauth/prepare', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({app_key: appKey, app_secret: appSecret}),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'prepare failed');
document.getElementById('oauth-auth-url').href = d.auth_url;
document.getElementById('oauth-auth-url').textContent = d.auth_url;
document.getElementById('oauth-step2').style.display = '';
out.className = 'status-line ok';
out.textContent = 'Auth URL generated. Open the link above and paste the code below.';
} catch (e) {
out.className = 'status-line err';
out.textContent = `Failed: ${e}`;
}
}
async function oauthExchange() {
const out = document.getElementById('oauth-step2-status');
const code = (document.getElementById('oauth-code').value || '').trim();
if (!code) {
out.className = 'status-line err';
out.textContent = 'Paste the authorization code from Dropbox first.';
return;
}
out.className = 'status-line warn';
out.textContent = 'Exchanging code for refresh token...';
try {
const r = await fetch('/api/backup/oauth/exchange', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code}),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'exchange failed');
out.className = 'status-line ok';
out.textContent = d.message || 'Dropbox connected successfully.';
document.getElementById('oauth-code').value = '';
document.getElementById('oauth-step2').style.display = 'none';
document.getElementById('oauth-step1-status').textContent = '';
await Promise.all([loadDropboxSettings(), loadHealth()]);
} catch (e) {
out.className = 'status-line err';
out.textContent = `Failed: ${e}`;
}
}
function oauthReset() {
document.getElementById('oauth-step2').style.display = 'none';
document.getElementById('oauth-code').value = '';
document.getElementById('oauth-step2-status').textContent = '';
document.getElementById('oauth-step1-status').textContent = '';
}
async function saveDropboxToken() { async function saveDropboxToken() {
const out = document.getElementById('dropbox-status'); const out = document.getElementById('dropbox-status');
const token = (document.getElementById('dropbox-token').value || '').trim();
const dropboxRoot = (document.getElementById('dropbox-root').value || '').trim(); const dropboxRoot = (document.getElementById('dropbox-root').value || '').trim();
const retentionCount = Math.max(1, parseInt((document.getElementById('retention-count').value || '14').trim(), 10) || 14); const retentionCount = Math.max(1, parseInt((document.getElementById('retention-count').value || '14').trim(), 10) || 14);
const scheduleEnabled = document.getElementById('schedule-enabled').value === 'true'; const scheduleEnabled = document.getElementById('schedule-enabled').value === 'true';
@ -319,23 +456,20 @@
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
token,
dropbox_root: dropboxRoot, dropbox_root: dropboxRoot,
retention_count: retentionCount, retention_count: retentionCount,
schedule_enabled: scheduleEnabled, schedule_enabled: scheduleEnabled,
schedule_interval_hours: scheduleIntervalHours schedule_interval_hours: scheduleIntervalHours,
}), }),
}); });
const raw = await r.text(); const raw = await r.text();
let d; let d;
try { try { d = JSON.parse(raw); } catch (_) {
d = JSON.parse(raw);
} catch (_) {
throw new Error(`HTTP ${r.status}: ${raw.slice(0, 180) || 'non-JSON response'}`); throw new Error(`HTTP ${r.status}: ${raw.slice(0, 180) || 'non-JSON response'}`);
} }
if (!d.ok) throw new Error(d.error || 'save failed'); if (!d.ok) throw new Error(d.error || 'save failed');
out.className = 'status-line ok'; out.className = 'status-line ok';
out.textContent = `Backup settings saved. Root: ${d.dropbox_root || dropboxRoot || '/novela'} • keep: ${d.retention_count || retentionCount} • schedule: ${(d.schedule_enabled ? 'on' : 'off')} (${d.schedule_interval_hours || scheduleIntervalHours}h)`; out.textContent = `Settings saved. Root: ${d.dropbox_root || dropboxRoot || '/novela'} • keep: ${d.retention_count || retentionCount} • schedule: ${d.schedule_enabled ? 'on' : 'off'} (${d.schedule_interval_hours || scheduleIntervalHours}h)`;
await Promise.all([loadDropboxSettings(), loadHealth()]); await Promise.all([loadDropboxSettings(), loadHealth()]);
} catch (e) { } catch (e) {
out.className = 'status-line err'; out.className = 'status-line err';
@ -352,7 +486,8 @@
await fetch('/api/backup/credentials', {method: 'DELETE'}); await fetch('/api/backup/credentials', {method: 'DELETE'});
out.className = 'status-line ok'; out.className = 'status-line ok';
out.textContent = 'Dropbox token removed.'; out.textContent = 'Dropbox token removed.';
document.getElementById('dropbox-token').value = ''; document.getElementById('app-key').value = '';
document.getElementById('app-secret').value = '';
document.getElementById('dropbox-root').value = '/novela'; document.getElementById('dropbox-root').value = '/novela';
document.getElementById('retention-count').value = 14; document.getElementById('retention-count').value = 14;
document.getElementById('schedule-enabled').value = 'false'; document.getElementById('schedule-enabled').value = 'false';
@ -364,11 +499,6 @@
} }
} }
function toggleDropboxToken() {
const el = document.getElementById('dropbox-token');
el.type = el.type === 'password' ? 'text' : 'password';
}
async function runBackup(dryRun) { async function runBackup(dryRun) {
const btnDry = document.getElementById('btn-dry'); const btnDry = document.getElementById('btn-dry');
const btnLive = document.getElementById('btn-live'); const btnLive = document.getElementById('btn-live');
@ -390,6 +520,8 @@
out.className = 'status-line ok'; out.className = 'status-line ok';
if (d.status === 'running') { if (d.status === 'running') {
out.textContent = `Backup started in background. id=${d.backup_id}, dry_run=${d.dry_run}`; out.textContent = `Backup started in background. id=${d.backup_id}, dry_run=${d.dry_run}`;
// Immediately kick off sidebar progress polling
if (typeof loadBackupProgress === 'function') loadBackupProgress();
} else { } else {
out.textContent = `Backup ${d.status}. id=${d.backup_id}, files=${d.files_count}, bytes=${d.size_bytes}, dry_run=${d.dry_run}`; out.textContent = `Backup ${d.status}. id=${d.backup_id}, files=${d.files_count}, bytes=${d.size_bytes}, dry_run=${d.dry_run}`;
} }
@ -408,7 +540,153 @@
} }
async function refreshAll() { async function refreshAll() {
await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory()]); await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots()]);
}
// ── Restore ─────────────────────────────────────────────────────────────
let _restoreFiles = [];
async function loadSnapshots() {
const sel = document.getElementById('snapshot-select');
try {
const r = await fetch('/api/backup/snapshots');
const d = await r.json();
if (!d.ok || !d.snapshots.length) {
sel.innerHTML = '<option value="">— no snapshots available —</option>';
return;
}
const current = sel.value;
sel.innerHTML = '<option value="">— select snapshot —</option>' +
d.snapshots.map(s => {
const label = s.created_at
? `${s.name} (${s.created_at.replace('T', ' ').replace('Z', ' UTC')})`
: s.name;
return `<option value="${esc(s.name)}"${s.name === current ? ' selected' : ''}>${esc(label)}</option>`;
}).join('');
} catch (_) {
sel.innerHTML = '<option value="">— Dropbox not configured —</option>';
}
}
async function onSnapshotChange() {
const name = document.getElementById('snapshot-select').value;
const panel = document.getElementById('restore-file-panel');
const status = document.getElementById('restore-status');
if (!name) {
panel.style.display = 'none';
_restoreFiles = [];
status.textContent = '';
return;
}
status.className = 'status-line warn';
status.textContent = 'Loading snapshot files…';
try {
const r = await fetch(`/api/backup/snapshots/${encodeURIComponent(name)}/files`);
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'failed');
_restoreFiles = d.files;
document.getElementById('restore-search').value = '';
panel.style.display = '';
renderRestoreFiles();
status.className = 'status-line ok';
status.textContent = `${d.files.length} file(s) in snapshot.`;
} catch (e) {
status.className = 'status-line err';
status.textContent = `Failed to load snapshot files: ${e}`;
panel.style.display = 'none';
}
}
function fmtBytes(bytes) {
if (!bytes) return '-';
if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
if (bytes >= 1024) return Math.round(bytes / 1024) + ' KB';
return bytes + ' B';
}
function renderRestoreFiles() {
const q = (document.getElementById('restore-search').value || '').toLowerCase().trim();
const body = document.getElementById('restore-file-body');
const filtered = q ? _restoreFiles.filter(f => f.path.toLowerCase().includes(q)) : _restoreFiles;
if (!filtered.length) {
body.innerHTML = '<tr><td colspan="6" style="color:var(--text-dim);padding:0.6rem 0.25rem">No files found.</td></tr>';
document.getElementById('btn-restore-selected').disabled = true;
return;
}
body.innerHTML = filtered.map(f => {
const ext = f.path.split('.').pop().toUpperCase();
const parts = f.path.split('/');
const name = parts[parts.length - 1];
const dir = parts.slice(0, -1).join('/');
const onDisk = f.exists_locally
? '<span class="ok" title="File already on disk">&#10003; exists</span>'
: '<span class="warn">missing</span>';
return `<tr>
<td><input type="checkbox" class="restore-chk" data-path="${esc(f.path)}" onchange="updateRestoreBtn()"/></td>
<td><span style="font-family:var(--mono);font-size:0.68rem;color:var(--text-dim)">${esc(ext)}</span></td>
<td><span style="font-size:0.8rem">${esc(name)}</span><br/><span style="font-size:0.68rem;color:var(--text-dim)">${esc(dir)}</span></td>
<td style="white-space:nowrap;font-family:var(--mono);font-size:0.72rem">${esc(fmtBytes(f.size))}</td>
<td style="font-family:var(--mono);font-size:0.72rem">${onDisk}</td>
<td><button class="btn" style="padding:0.3rem 0.6rem" data-path="${esc(f.path)}" onclick="restoreRowBtn(this)">Restore</button></td>
</tr>`;
}).join('');
updateRestoreBtn();
}
function updateRestoreBtn() {
const checked = document.querySelectorAll('.restore-chk:checked').length;
document.getElementById('btn-restore-selected').disabled = checked === 0;
}
function selectAllRestoreFiles() {
document.querySelectorAll('.restore-chk').forEach(el => { el.checked = true; });
updateRestoreBtn();
}
function clearRestoreSelection() {
document.querySelectorAll('.restore-chk').forEach(el => { el.checked = false; });
updateRestoreBtn();
}
function restoreRowBtn(btn) {
const snapshotName = document.getElementById('snapshot-select').value;
_doRestore(snapshotName, [btn.dataset.path]);
}
function restoreSelected() {
const snapshotName = document.getElementById('snapshot-select').value;
const paths = Array.from(document.querySelectorAll('.restore-chk:checked')).map(el => el.dataset.path);
_doRestore(snapshotName, paths);
}
async function _doRestore(snapshotName, paths) {
if (!paths.length) return;
const status = document.getElementById('restore-status');
status.className = 'status-line warn';
status.textContent = `Restoring ${paths.length} file(s)…`;
try {
const r = await fetch('/api/backup/restore', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({snapshot_name: snapshotName, files: paths}),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'failed');
const failed = (d.results || []).filter(x => !x.ok);
if (failed.length) {
status.className = 'status-line warn';
status.textContent = `Restored ${d.restored}/${d.total}. Errors: ${failed.map(x => `${x.path}: ${x.error}`).join(' | ')}`;
} else {
status.className = 'status-line ok';
status.textContent = `Restored ${d.restored}/${d.total} file(s) successfully.`;
}
// Refresh exists_locally state
await onSnapshotChange();
} catch (e) {
status.className = 'status-line err';
status.textContent = `Restore failed: ${e}`;
}
} }
refreshAll(); refreshAll();

View File

@ -4,8 +4,13 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — {{ title or filename }}</title> <title>Novela — {{ title or filename }}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/> <link rel="stylesheet" href="/static/sidebar.css"/>
<link rel="stylesheet" href="/static/book.css"/> <link rel="stylesheet" href="/static/book.css"/>
</head> </head>
@ -25,6 +30,13 @@
{% endif %} {% endif %}
</div> </div>
{% set r = (rating | default(0)) | int %}
<div class="star-row interactive" id="book-stars">
{% for i in range(1, 6) %}
<span class="star {% if i <= r %}filled{% endif %}" onclick="rateBook({{ i }})"></span>
{% endfor %}
</div>
<button class="btn-wtr {% if want_to_read %}active{% endif %}" id="wtr-btn" onclick="toggleWtr()"> <button class="btn-wtr {% if want_to_read %}active{% endif %}" id="wtr-btn" onclick="toggleWtr()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="{% if want_to_read %}currentColor{% else %}none{% endif %}" stroke="currentColor" stroke-width="2.5" id="wtr-svg"> <svg width="12" height="12" viewBox="0 0 24 24" fill="{% if want_to_read %}currentColor{% else %}none{% endif %}" stroke="currentColor" stroke-width="2.5" id="wtr-svg">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/> <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
@ -42,7 +54,7 @@
{% if series %} {% if series %}
<div class="meta-row"> <div class="meta-row">
<span class="meta-label">Series</span> <span class="meta-label">Series</span>
<span class="meta-value">{{ series }}{% if series_index %} [{{ series_index }}]{% endif %}</span> <span class="meta-value">{{ series }}{% if series_index is defined and (series_index or series_suffix or series_is_indexed) %} [{{ series_index }}{{ series_suffix }}]{% endif %}</span>
</div> </div>
{% endif %} {% endif %}
<div class="meta-row"> <div class="meta-row">
@ -58,7 +70,7 @@
<span class="meta-label">Status</span> <span class="meta-label">Status</span>
<span class="meta-value"> <span class="meta-value">
{% set st = publication_status | lower %} {% set st = publication_status | lower %}
<span class="status-pill {% if st == 'complete' %}status-complete{% elif st == 'ongoing' %}status-ongoing{% elif st == 'hiatus' %}status-hiatus{% endif %}"> <span class="status-pill {% if st == 'complete' %}status-complete{% elif st == 'ongoing' %}status-ongoing{% elif st == 'temporary hold' %}status-temporary-hold{% elif st == 'long-term hold' %}status-long-term-hold{% endif %}">
{{ publication_status }} {{ publication_status }}
</span> </span>
</span> </span>
@ -143,6 +155,16 @@
Mark as unread Mark as unread
</button> </button>
{% endif %} {% endif %}
{% if storage_type == 'db' %}
<a class="btn-secondary" href="/api/library/export-epub/{{ filename | urlencode }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Export EPUB
</a>
{% else %}
<a class="btn-secondary" href="/download/{{ filename | urlencode }}"> <a class="btn-secondary" href="/download/{{ filename | urlencode }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
@ -151,6 +173,7 @@
</svg> </svg>
Download Download
</a> </a>
{% endif %}
<button class="btn-secondary" onclick="openMarkReadModal()"> <button class="btn-secondary" onclick="openMarkReadModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/> <polyline points="20 6 9 17 4 12"/>
@ -172,6 +195,7 @@
</svg> </svg>
Edit Edit
</button> </button>
{% if filename.endswith('.epub') and storage_type != 'db' %}
<a class="btn-secondary" href="/library/editor/{{ filename | urlencode }}"> <a class="btn-secondary" href="/library/editor/{{ filename | urlencode }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="16 18 22 12 16 6"/> <polyline points="16 18 22 12 16 6"/>
@ -179,6 +203,24 @@
</svg> </svg>
Edit EPUB Edit EPUB
</a> </a>
{% endif %}
{% if storage_type == 'db' %}
<a class="btn-secondary" href="/library/editor/{{ filename | urlencode }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
Edit chapters
</a>
{% endif %}
{% if filename.endswith('.epub') and storage_type != 'db' %}
<button class="btn-secondary" id="convert-db-btn" onclick="convertToDb()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
Convert to DB
</button>
{% endif %}
<input type="file" id="cover-input" accept="image/*" style="display:none" onchange="uploadCover(this)"/> <input type="file" id="cover-input" accept="image/*" style="display:none" onchange="uploadCover(this)"/>
<button class="btn-secondary" onclick="document.getElementById('cover-input').click()"> <button class="btn-secondary" onclick="document.getElementById('cover-input').click()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
@ -213,11 +255,29 @@
</button> </button>
</div> </div>
<div class="edit-field"><label class="edit-label">Title</label><input class="edit-input" id="ed-title" type="text"/></div> <div class="edit-field"><label class="edit-label">Title</label><input class="edit-input" id="ed-title" type="text"/></div>
<div class="edit-field"><label class="edit-label">Author</label><input class="edit-input" id="ed-author" type="text"/></div> <div class="edit-field">
<div class="edit-field"><label class="edit-label">Publisher</label><input class="edit-input" id="ed-publisher" type="text"/></div> <label class="edit-label">Author</label>
<div class="genre-wrap">
<input class="edit-input" id="ed-author" type="text" autocomplete="off"/>
<div class="genre-dropdown" id="author-dropdown" style="display:none"></div>
</div>
</div>
<div class="edit-field">
<label class="edit-label">Publisher</label>
<div class="genre-wrap">
<input class="edit-input" id="ed-publisher" type="text" autocomplete="off"/>
<div class="genre-dropdown" id="publisher-dropdown" style="display:none"></div>
</div>
</div>
<div class="edit-row"> <div class="edit-row">
<div class="edit-field"><label class="edit-label">Series</label><input class="edit-input" id="ed-series" type="text"/></div> <div class="edit-field">
<div class="edit-field"><label class="edit-label">Volume</label><input class="edit-input" id="ed-series-index" type="number" min="0"/></div> <label class="edit-label">Series</label>
<div class="genre-wrap">
<input class="edit-input" id="ed-series" type="text" autocomplete="off"/>
<div class="genre-dropdown" id="series-dropdown" style="display:none"></div>
</div>
</div>
<div class="edit-field"><label class="edit-label">Volume</label><input class="edit-input" id="ed-series-index" type="text" placeholder="e.g. 1 or 21a"/></div>
</div> </div>
<div class="edit-field"> <div class="edit-field">
<label class="edit-label">Status</label> <label class="edit-label">Status</label>
@ -225,7 +285,8 @@
<option value=""></option> <option value=""></option>
<option value="Complete">Complete</option> <option value="Complete">Complete</option>
<option value="Ongoing">Ongoing</option> <option value="Ongoing">Ongoing</option>
<option value="Hiatus">Hiatus</option> <option value="Temporary Hold">Temporary Hold</option>
<option value="Long-Term Hold">Long-Term Hold</option>
</select> </select>
</div> </div>
<div class="edit-field"><label class="edit-label">Source URL</label><input class="edit-input" id="ed-url" type="url" placeholder="https://…"/></div> <div class="edit-field"><label class="edit-label">Source URL</label><input class="edit-input" id="ed-url" type="url" placeholder="https://…"/></div>
@ -288,7 +349,7 @@
<div class="modal-backdrop" id="delete-modal"> <div class="modal-backdrop" id="delete-modal">
<div class="modal"> <div class="modal">
<h3>Delete book</h3> <h3>Delete book</h3>
<p>This will permanently delete the EPUB file and all reading progress for <strong id="delete-title"></strong>. This cannot be undone.</p> <p>{% if storage_type == 'db' %}This will permanently delete the book and all its chapters from the database for{% else %}This will permanently delete the file and all reading progress for{% endif %} <strong id="delete-title"></strong>. This cannot be undone.</p>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-secondary" onclick="document.getElementById('delete-modal').classList.remove('open')">Cancel</button> <button class="btn-secondary" onclick="document.getElementById('delete-modal').classList.remove('open')">Cancel</button>
<button class="btn-danger" onclick="confirmDelete()">Delete</button> <button class="btn-danger" onclick="confirmDelete()">Delete</button>
@ -297,6 +358,8 @@
</div> </div>
<script> <script>
const STORAGE_TYPE = {{ storage_type | tojson }};
const BOOK = { const BOOK = {
filename: {{ filename | tojson }}, filename: {{ filename | tojson }},
title: {{ (title or filename) | tojson }}, title: {{ (title or filename) | tojson }},
@ -304,6 +367,7 @@
publisher: {{ (publisher or '') | tojson }}, publisher: {{ (publisher or '') | tojson }},
series: {{ (series or '') | tojson }}, series: {{ (series or '') | tojson }},
series_index: {{ series_index or 0 }}, series_index: {{ series_index or 0 }},
series_suffix: {{ (series_suffix or '') | tojson }},
publication_status: {{ (publication_status or '') | tojson }}, publication_status: {{ (publication_status or '') | tojson }},
source_url: {{ (source_url or '') | tojson }}, source_url: {{ (source_url or '') | tojson }},
publish_date: {{ (publish_date or '') | tojson }}, publish_date: {{ (publish_date or '') | tojson }},
@ -312,8 +376,33 @@
subgenres: {{ subgenres | tojson }}, subgenres: {{ subgenres | tojson }},
tags: {{ tags | tojson }}, tags: {{ tags | tojson }},
has_cover: {{ 'true' if has_cover else 'false' }}, has_cover: {{ 'true' if has_cover else 'false' }},
rating: {{ rating or 0 }},
}; };
</script> </script>
<script>
async function convertToDb() {
const btn = document.getElementById('convert-db-btn');
if (!confirm('Convert this EPUB to DB storage?\nThe EPUB file will be deleted from disk.\nAll reading progress, bookmarks and ratings are preserved.')) return;
btn.disabled = true;
btn.textContent = 'Converting…';
try {
const resp = await fetch('/api/library/convert-to-db/' + encodeURIComponent(BOOK.filename), { method: 'POST' });
const data = await resp.json();
if (data.ok) {
window.location.href = '/library/book/' + encodeURIComponent(data.new_filename);
} else {
alert('Conversion failed: ' + (data.error || 'unknown error'));
btn.disabled = false;
btn.textContent = 'Convert to DB';
}
} catch (e) {
alert('Conversion failed: ' + e);
btn.disabled = false;
btn.textContent = 'Convert to DB';
}
}
</script>
<script src="/static/books.js"></script>
<script src="/static/book.js"></script> <script src="/static/book.js"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,134 @@
<!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="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="stylesheet" href="/static/theme.css"/>
<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

@ -0,0 +1,937 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela Bulk Import</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body { background: var(--bg); color: var(--text); font-family: var(--serif); }
.main {
margin-left: var(--sidebar);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem 5rem;
}
@media (max-width: 768px) { .main { margin-left: 0; padding: 4rem 1rem 4rem; } }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 2rem;
width: 100%; max-width: 680px; margin-bottom: 1.5rem;
}
.card-wide { max-width: 1100px; }
.card-title {
font-size: 0.7rem; font-family: var(--mono);
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--accent); margin-bottom: 1.25rem;
}
label {
display: block; font-size: 0.78rem; font-family: var(--mono);
color: var(--text); margin-bottom: 0.4rem; letter-spacing: 0.04em;
}
input[type="text"], input[type="date"], select {
width: 100%; background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text);
font-family: var(--mono); font-size: 0.85rem;
padding: 0.6rem 0.85rem; outline: none;
transition: border-color 0.15s; margin-bottom: 1rem;
appearance: none; -webkit-appearance: none;
}
input[type="text"]:focus, input[type="date"]:focus, select:focus { border-color: var(--accent); }
select { cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238a8278' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.75rem center; padding-right: 2.2rem; }
select option { background: var(--surface2); }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.row-2 input, .row-2 select { margin-bottom: 0; }
.row-2 > div { display: flex; flex-direction: column; margin-bottom: 1rem; }
.row-2 > div label { margin-bottom: 0.4rem; }
button {
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.75rem 1.25rem; background: var(--accent); color: #0f0e0c;
border: none; border-radius: var(--radius); font-family: var(--mono);
font-size: 0.85rem; font-weight: 500; letter-spacing: 0.04em;
cursor: pointer; transition: background 0.15s, transform 0.1s;
}
button:hover { background: var(--accent2); }
button:active { transform: scale(0.99); }
button:disabled { background: var(--text-faint); cursor: not-allowed; color: var(--bg); }
.btn-outline {
background: var(--surface2); color: var(--text-dim);
border: 1px solid var(--border);
}
.btn-outline:hover { background: var(--surface); color: var(--text); }
.btn-sm {
padding: 0.35rem 0.75rem; font-size: 0.75rem;
}
.btn-danger { background: rgba(200,90,58,0.15); color: var(--error); border: 1px solid rgba(200,90,58,0.3); }
.btn-danger:hover { background: rgba(200,90,58,0.25); color: var(--error); }
/* Placeholder chips */
.ph-chips {
display: flex; flex-wrap: wrap; gap: 0.4rem;
margin-bottom: 1rem;
}
.ph-chip {
display: inline-flex; align-items: center;
padding: 0.25rem 0.65rem;
border-radius: var(--radius);
font-family: var(--mono); font-size: 0.78rem;
cursor: grab; user-select: none;
background: var(--surface2); border: 1px solid var(--border);
color: var(--chip-color, var(--text-dim));
transition: border-color 0.15s, color 0.15s;
}
.ph-chip:hover { border-color: var(--chip-color, var(--accent)); color: var(--chip-color, var(--text)); }
.ph-chip:active { cursor: grabbing; }
.pattern-preview {
font-family: var(--mono); font-size: 0.78rem;
color: var(--text-dim); margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
letter-spacing: 0.02em; word-break: break-all;
}
.pattern-preview .tok { font-weight: 500; }
.pattern-preview .delim { color: var(--text); }
.test-parse-result {
font-family: var(--mono); font-size: 0.78rem;
color: var(--text-dim); line-height: 1.9;
padding: 0.6rem 0.75rem; background: var(--bg);
border: 1px solid var(--border); border-radius: var(--radius);
display: none;
}
.test-parse-result.visible { display: block; }
.tpr-field { color: var(--text-faint); }
.tpr-val { color: var(--text); }
/* File drop */
.file-drop {
border: 1px dashed var(--border); border-radius: var(--radius);
padding: 2rem; text-align: center; cursor: pointer;
transition: border-color 0.15s; position: relative; margin-bottom: 0;
}
.file-drop:hover, .file-drop.drag-over { border-color: var(--accent); }
.file-drop input[type="file"] {
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%;
}
.file-drop-label {
font-family: var(--mono); font-size: 0.82rem; color: var(--text-dim);
pointer-events: none;
}
.file-drop-label span { color: var(--accent); }
.file-count {
font-family: var(--mono); font-size: 0.8rem;
color: var(--text-dim); margin-top: 0.75rem;
display: none;
}
.file-count.visible { display: block; }
/* Preview table */
.table-scroll {
overflow-x: auto; overflow-y: auto; max-height: 65vh;
border: 1px solid var(--border); border-radius: var(--radius);
margin-bottom: 1rem;
}
.table-scroll::-webkit-scrollbar { width: 6px; height: 6px; }
.table-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
table {
width: 100%; border-collapse: collapse;
font-family: var(--mono); font-size: 0.78rem;
}
thead th {
background: var(--surface2); color: var(--text-dim);
padding: 0.6rem 0.75rem; text-align: left;
font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase;
font-size: 0.68rem; position: sticky; top: 0; z-index: 2;
border-bottom: 1px solid var(--border); white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--border); }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: rgba(255,162,14,0.04); }
tbody tr.row-warn { background: rgba(200,160,58,0.06); }
tbody tr.row-warn:hover { background: rgba(200,160,58,0.10); }
td {
padding: 0.45rem 0.6rem; color: var(--text); vertical-align: middle;
}
td.td-num { color: var(--text-faint); font-size: 0.7rem; width: 2.5rem; text-align: right; padding-right: 0.75rem; }
td.td-filename { color: var(--text-dim); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
td[contenteditable="true"] { outline: none; cursor: text; min-width: 60px; }
td[contenteditable="true"]:focus {
background: rgba(255,162,14,0.08);
box-shadow: inset 0 0 0 1px var(--accent);
}
td[contenteditable="true"]:empty::before {
content: attr(data-placeholder); color: var(--text-faint); font-style: italic;
}
td.td-warn { color: var(--warning); font-size: 0.72rem; width: 1.5rem; text-align: center; }
td.td-skip { width: 2.5rem; text-align: center; }
td.td-skip input[type="checkbox"] { cursor: pointer; accent-color: var(--error); }
tbody tr.row-dup { background: rgba(200,90,58,0.06); }
tbody tr.row-dup:hover { background: rgba(200,90,58,0.10); }
tbody tr.row-dup.row-skipped { opacity: 0.38; }
.cnt-dup { color: var(--error); }
.dup-actions {
display: flex; gap: 0.5rem; align-items: center;
font-family: var(--mono); font-size: 0.73rem;
}
.dup-actions button {
padding: 0.2rem 0.55rem; font-size: 0.7rem;
background: var(--surface2); color: var(--text-dim);
border: 1px solid var(--border); border-radius: var(--radius);
}
.dup-actions button:hover { color: var(--text); background: var(--surface); }
.preview-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;
}
.preview-header .card-title { margin-bottom: 0; }
.preview-stats {
font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim);
}
.preview-stats .cnt-ok { color: var(--success); }
.preview-stats .cnt-warn { color: var(--warning); }
/* Progress */
.progress-wrap { margin-top: 1.25rem; display: none; }
.progress-wrap.visible { display: block; }
.progress-bar-outer {
background: var(--bg); border: 1px solid var(--border);
border-radius: 100px; height: 6px; margin-bottom: 0.75rem; overflow: hidden;
}
.progress-bar-inner {
height: 100%; background: var(--accent); border-radius: 100px;
width: 0%; transition: width 0.2s ease;
}
.progress-status {
font-family: var(--mono); font-size: 0.78rem;
color: var(--text-dim); min-height: 1.2em;
}
/* Result */
.result-box { display: none; }
.result-box.visible { display: block; }
.result-ok {
font-family: var(--mono); font-size: 0.82rem; color: var(--success);
margin-bottom: 0.75rem;
}
.skipped-list {
margin-top: 0.75rem; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg);
max-height: 200px; overflow-y: auto;
}
.skipped-list::-webkit-scrollbar { width: 4px; }
.skipped-list::-webkit-scrollbar-thumb { background: var(--border); }
.skipped-item {
padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border);
font-family: var(--mono); font-size: 0.72rem; color: var(--error);
display: flex; gap: 0.75rem;
}
.skipped-item:last-child { border-bottom: none; }
.skipped-item .sk-file { color: var(--text-dim); flex-shrink: 0; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.divider { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
.suggest-wrap { position: relative; margin-bottom: 1rem; }
.suggest-wrap input { margin-bottom: 0; }
.suggest-dropdown {
position: absolute; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); max-height: 180px; overflow-y: auto;
z-index: 300; width: 100%; margin-top: 2px; box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.suggest-option { padding: 0.45rem 0.75rem; font-family: var(--mono); font-size: 0.8rem; color: var(--text-dim); cursor: pointer; }
.suggest-option:hover, .suggest-option.active { background: var(--surface2); color: var(--text); }
.hint {
font-family: var(--mono); font-size: 0.73rem; color: var(--text-dim);
margin-top: -0.6rem; margin-bottom: 1rem; line-height: 1.6;
}
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<!-- Card 1: Pattern -->
<div class="card">
<div class="card-title">Filename Pattern</div>
<label>Pattern</label>
<input type="text" id="pattern-input" value="%series% - %volume% - %title% - %year%" oninput="onPatternChange()" style="font-family:var(--mono)"/>
<label>Available placeholders <span style="color:var(--text-dim)">(click or drag to cursor position)</span></label>
<div class="ph-chips" id="ph-chips"></div>
<div class="pattern-preview" id="pattern-preview"></div>
<label>Test filename (optional)</label>
<input type="text" id="test-input" placeholder="" oninput="updateTestParse()"/>
<div class="test-parse-result" id="test-parse-result"></div>
</div>
<!-- Card 2: Shared metadata -->
<div class="card">
<div class="card-title">Shared Metadata</div>
<p class="hint">Applies to all files. Filled-in fields override values parsed from the pattern.</p>
<div class="row-2">
<div>
<label>Author</label>
<div class="suggest-wrap">
<input type="text" id="shared-author" autocomplete="off" oninput="updatePreview()"/>
<div class="suggest-dropdown" id="author-dropdown" style="display:none"></div>
</div>
</div>
<div>
<label>Publisher</label>
<div class="suggest-wrap">
<input type="text" id="shared-publisher" autocomplete="off" oninput="updatePreview()"/>
<div class="suggest-dropdown" id="publisher-dropdown" style="display:none"></div>
</div>
</div>
</div>
<div class="row-2">
<div>
<label>Series</label>
<div class="suggest-wrap">
<input type="text" id="shared-series" autocomplete="off" oninput="updatePreview()"/>
<div class="suggest-dropdown" id="series-dropdown" style="display:none"></div>
</div>
</div>
<div>
<label>Status</label>
<select id="shared-status" oninput="updatePreview()">
<option value="">(none)</option>
<option value="Complete">Complete</option>
<option value="Ongoing">Ongoing</option>
<option value="Temporary Hold">Temporary Hold</option>
<option value="Long-Term Hold">Long-Term Hold</option>
</select>
</div>
<div>
<label>Genres <span style="color:var(--text-dim)">(comma-separated)</span></label>
<input type="text" id="shared-genres" oninput="updatePreview()"/>
</div>
</div>
<label>Tags <span style="color:var(--text-dim)">(comma-separated)</span></label>
<input type="text" id="shared-tags"/>
</div>
<!-- Card 3: Files -->
<div class="card">
<div class="card-title">Select Files</div>
<div class="file-drop" id="file-drop">
<input type="file" id="file-input" multiple accept=".cbr,.cbz,.epub,.pdf"
onchange="onFilesSelected(this.files)"/>
<div class="file-drop-label">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin-bottom:0.5rem;display:block;margin:0 auto 0.5rem">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Click or drop files here &mdash; <span>CBR, CBZ, EPUB, PDF</span>
</div>
</div>
<div class="file-count" id="file-count"></div>
</div>
<!-- Card 4: Preview table -->
<div class="card card-wide" id="preview-card" style="display:none">
<div class="preview-header">
<div class="card-title">Preview</div>
<div class="preview-stats" id="preview-stats"></div>
</div>
<div class="table-scroll">
<table>
<thead>
<tr>
<th style="width:2.5rem">#</th>
<th>Filename</th>
<th>Series</th>
<th>Vol</th>
<th>Title</th>
<th>Author</th>
<th>Publisher</th>
<th>Year</th>
<th id="th-skip" style="width:2.5rem;display:none" title="Skip this file during import">Skip</th>
<th style="width:1.5rem"></th>
</tr>
</thead>
<tbody id="preview-body"></tbody>
</table>
</div>
<p class="hint" style="margin-bottom:0">
Cells are editable — click to adjust.
<span style="color:var(--warning)">Yellow rows</span> did not match the pattern.
<span style="color:var(--error)">Red rows</span> already exist in the library — check the Skip box to exclude them.
</p>
</div>
<!-- Card 5: Import -->
<div class="card" id="import-card" style="display:none">
<div class="card-title">Import</div>
<button id="import-btn" onclick="startImport()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span id="import-btn-label">Import files</span>
</button>
<div class="progress-wrap" id="progress-wrap">
<div class="progress-bar-outer">
<div class="progress-bar-inner" id="progress-bar"></div>
</div>
<div class="progress-status" id="progress-status"></div>
</div>
<div class="result-box" id="result-box">
<hr class="divider"/>
<div class="result-ok" id="result-ok"></div>
<div id="result-skipped"></div>
<button class="btn-outline" onclick="goToLibrary()" style="margin-top:1rem;width:auto">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
Go to library
</button>
</div>
</div>
</main>
<script src="/static/books.js"></script>
<script>
// ── State ──────────────────────────────────────────────────────────────────
const PLACEHOLDER_META = [
{ key: 'series', label: '%series%', color: 'var(--accent)' },
{ key: 'volume', label: '%volume%', color: '#4a90b8' },
{ key: 'title', label: '%title%', color: 'var(--success)' },
{ key: 'year', label: '%year%', color: 'var(--warning)' },
{ key: 'month', label: '%month%', color: '#c8a03a' },
{ key: 'day', label: '%day%', color: '#c8a03a' },
{ key: 'author', label: '%author%', color: '#9878c8' },
{ key: 'publisher', label: '%publisher%', color: '#4ab8a0' },
{ key: 'ignore', label: '%ignore%', color: 'var(--text-faint)' },
];
let selectedFiles = [];
let parsedRows = []; // [{original_filename, series, volume, title, year, author, publisher, status, genres, tags, _warn}]
const BATCH_SIZE = 5;
// ── Placeholder chips ──────────────────────────────────────────────────────
function initChips() {
const container = document.getElementById('ph-chips');
container.innerHTML = '';
PLACEHOLDER_META.forEach(ph => {
const chip = document.createElement('span');
chip.className = 'ph-chip';
chip.style.setProperty('--chip-color', ph.color);
chip.textContent = ph.label;
chip.draggable = true;
chip.addEventListener('click', () => {
const input = document.getElementById('pattern-input');
const pos = input.selectionStart ?? input.value.length;
input.value = input.value.slice(0, pos) + ph.label + input.value.slice(pos);
const newPos = pos + ph.label.length;
input.setSelectionRange(newPos, newPos);
input.focus();
onPatternChange();
});
chip.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', ph.label);
e.dataTransfer.effectAllowed = 'copy';
});
container.appendChild(chip);
});
// Allow dropping chips onto pattern input
const patInput = document.getElementById('pattern-input');
patInput.addEventListener('drop', () => { setTimeout(onPatternChange, 0); });
}
function renderPatternPreview() {
const pattern = document.getElementById('pattern-input').value;
const parts = pattern.split(/(%\w+%)/);
let html = '';
parts.forEach(part => {
if (/^%\w+%$/.test(part)) {
const key = part.slice(1, -1);
const meta = PLACEHOLDER_META.find(p => p.key === key);
const color = meta ? meta.color : 'var(--text-dim)';
html += `<span class="tok" style="color:${color}">${esc(part)}</span>`;
} else if (part) {
html += `<span class="delim">${esc(part)}</span>`;
}
});
document.getElementById('pattern-preview').innerHTML = html || '<span class="delim">(empty)</span>';
}
function onPatternChange() {
renderPatternPreview();
updateTestParse();
updatePreview();
}
// ── Filename parser ────────────────────────────────────────────────────────
function patternToRegex(pattern) {
const parts = pattern.split(/(%\w+%)/);
const fields = [];
let regexStr = '^';
parts.forEach(part => {
if (/^%\w+%$/.test(part)) {
fields.push(part.slice(1, -1));
regexStr += '(.*?)';
} else {
regexStr += part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
});
regexStr += '$';
return { regex: new RegExp(regexStr), fields };
}
function parseFilename(stem, pattern) {
const { regex, fields } = patternToRegex(pattern);
const match = regex.exec(stem);
const result = {};
if (match) {
fields.forEach((field, i) => {
if (field !== 'ignore') result[field] = match[i + 1].trim();
});
result._warn = false;
} else {
result.title = stem;
result._warn = true;
}
return result;
}
function updateTestParse() {
const raw = document.getElementById('test-input').value.trim();
const box = document.getElementById('test-parse-result');
if (!raw) { box.classList.remove('visible'); return; }
const stem = raw.replace(/\.[^.]+$/, '');
const pattern = document.getElementById('pattern-input').value;
const parsed = parseFilename(stem, pattern);
const fields = PLACEHOLDER_META.filter(p => p.key !== 'ignore' && parsed[p.key] !== undefined);
let html = '';
fields.forEach(p => {
html += `<span class="tpr-field">${esc(p.key)}:</span> <span class="tpr-val">${esc(parsed[p.key])}</span> `;
});
if (!html) html = `<span class="tpr-field">titel:</span> <span class="tpr-val">${esc(parsed.title || stem)}</span>`;
box.innerHTML = html.trim();
box.classList.add('visible');
}
// ── File selection ─────────────────────────────────────────────────────────
function onFilesSelected(files) {
selectedFiles = Array.from(files).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
const cnt = document.getElementById('file-count');
cnt.textContent = selectedFiles.length + ' file' + (selectedFiles.length !== 1 ? 's' : '') + ' selected';
cnt.classList.add('visible');
updatePreview();
document.getElementById('preview-card').style.display = '';
document.getElementById('import-card').style.display = '';
}
// Drag & drop
const dropEl = document.getElementById('file-drop');
dropEl.addEventListener('dragover', e => { e.preventDefault(); dropEl.classList.add('drag-over'); });
dropEl.addEventListener('dragleave', () => dropEl.classList.remove('drag-over'));
dropEl.addEventListener('drop', e => {
e.preventDefault();
dropEl.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length) {
document.getElementById('file-input').files = files;
onFilesSelected(files);
}
});
// ── Preview table ──────────────────────────────────────────────────────────
function updatePreview() {
if (!selectedFiles.length) return;
const pattern = document.getElementById('pattern-input').value;
const sharedSeries = document.getElementById('shared-series').value.trim();
parsedRows = selectedFiles.map(f => {
const stem = f.name.replace(/\.[^.]+$/, '');
const parsed = parseFilename(stem, pattern);
return {
original_filename: f.name,
series: sharedSeries || parsed.series || '',
volume: parsed.volume || '',
title: parsed.title || stem,
year: parsed.year || '',
author: parsed.author || '',
publisher: parsed.publisher || '',
status: '',
genres: '',
tags: '',
_warn: parsed._warn || false,
_duplicate: false,
_skip: false,
};
});
renderPreviewTable();
checkDuplicates();
}
async function checkDuplicates() {
if (!parsedRows.length) return;
const sharedAuthor = document.getElementById('shared-author').value.trim();
const items = parsedRows.map(r => ({
title: r.title,
author: r.author || sharedAuthor,
volume: r.volume,
}));
try {
const resp = await fetch('/api/bulk-check-duplicates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
if (!resp.ok) return;
const data = await resp.json();
if (!Array.isArray(data.duplicates)) return;
data.duplicates.forEach((isDup, i) => {
if (!parsedRows[i]) return;
parsedRows[i]._duplicate = isDup;
if (isDup) parsedRows[i]._skip = true;
});
renderPreviewTable();
} catch {}
}
function renderPreviewTable() {
const sharedAuthor = document.getElementById('shared-author').value.trim();
const sharedPublisher = document.getElementById('shared-publisher').value.trim();
const tbody = document.getElementById('preview-body');
tbody.innerHTML = '';
const hasDups = parsedRows.some(r => r._duplicate);
const thSkip = document.getElementById('th-skip');
if (thSkip) thSkip.style.display = hasDups ? '' : 'none';
let warnCount = 0;
let dupCount = 0;
let skipCount = 0;
parsedRows.forEach((row, i) => {
if (row._warn) warnCount++;
if (row._duplicate) dupCount++;
if (row._skip) skipCount++;
const tr = document.createElement('tr');
const classes = [];
if (row._warn) classes.push('row-warn');
if (row._duplicate) classes.push('row-dup');
if (row._duplicate && row._skip) classes.push('row-skipped');
if (classes.length) tr.className = classes.join(' ');
// #
const tdNum = document.createElement('td');
tdNum.className = 'td-num';
tdNum.textContent = i + 1;
tr.appendChild(tdNum);
// Filename (read-only)
const tdFn = document.createElement('td');
tdFn.className = 'td-filename';
tdFn.title = row.original_filename;
tdFn.textContent = row.original_filename;
tr.appendChild(tdFn);
// Editable fields
const fields = [
{ key: 'series', placeholder: '—' },
{ key: 'volume', placeholder: '—' },
{ key: 'title', placeholder: 'Title' },
{ key: 'author', placeholder: sharedAuthor || '—' },
{ key: 'publisher', placeholder: sharedPublisher || '—' },
{ key: 'year', placeholder: '—' },
];
fields.forEach(({ key, placeholder }) => {
const td = document.createElement('td');
td.contentEditable = 'true';
td.dataset.row = i;
td.dataset.field = key;
td.dataset.placeholder = placeholder;
td.textContent = row[key] || '';
td.addEventListener('input', () => {
parsedRows[i][key] = td.textContent.trim();
});
td.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); td.blur(); }
});
td.addEventListener('paste', e => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
});
tr.appendChild(td);
});
// Skip checkbox (only shown when duplicates exist)
const tdSkip = document.createElement('td');
tdSkip.className = 'td-skip';
tdSkip.style.display = hasDups ? '' : 'none';
if (row._duplicate) {
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = row._skip;
cb.title = 'Skip this file during import';
cb.addEventListener('change', () => {
parsedRows[i]._skip = cb.checked;
tr.classList.toggle('row-skipped', cb.checked);
renderPreviewStats();
});
tdSkip.appendChild(cb);
}
tr.appendChild(tdSkip);
// Warning indicator
const tdW = document.createElement('td');
tdW.className = 'td-warn';
if (row._warn) tdW.title = 'Pattern did not match — check the values';
if (row._duplicate && !row._warn) tdW.title = 'Already exists in the library';
tdW.textContent = row._warn ? '⚠' : (row._duplicate ? '⊘' : '');
if (row._duplicate && !row._warn) tdW.style.color = 'var(--error)';
tr.appendChild(tdW);
tbody.appendChild(tr);
});
renderPreviewStats();
}
function renderPreviewStats() {
const dupCount = parsedRows.filter(r => r._duplicate).length;
const skipCount = parsedRows.filter(r => r._skip).length;
const warnCount = parsedRows.filter(r => r._warn).length;
const importCount = parsedRows.length - skipCount;
const statsEl = document.getElementById('preview-stats');
let stats = `<span class="cnt-ok">${importCount} to import</span>`;
if (warnCount) stats += ` &nbsp; <span class="cnt-warn">${warnCount} to check</span>`;
if (dupCount) {
stats += ` &nbsp; <span class="cnt-dup">${dupCount} duplicate${dupCount !== 1 ? 's' : ''}</span>`;
stats += ` &nbsp; <span class="dup-actions">`;
stats += `<button onclick="setAllDuplicatesSkip(true)">Skip all</button>`;
stats += `<button onclick="setAllDuplicatesSkip(false)">Import all</button>`;
stats += `</span>`;
}
statsEl.innerHTML = stats;
document.getElementById('import-btn-label').textContent =
`Import ${importCount} file${importCount !== 1 ? 's' : ''}`;
}
function setAllDuplicatesSkip(skip) {
parsedRows.forEach(r => { if (r._duplicate) r._skip = skip; });
renderPreviewTable();
}
// ── Import ─────────────────────────────────────────────────────────────────
async function startImport() {
if (!selectedFiles.length) return;
const shared = {
author: document.getElementById('shared-author').value.trim(),
publisher: document.getElementById('shared-publisher').value.trim(),
series: document.getElementById('shared-series').value.trim(),
status: document.getElementById('shared-status').value,
genres: document.getElementById('shared-genres').value.trim(),
tags: document.getElementById('shared-tags').value.trim(),
};
const btn = document.getElementById('import-btn');
btn.disabled = true;
document.getElementById('progress-wrap').classList.add('visible');
document.getElementById('result-box').classList.remove('visible');
// Filter out rows the user chose to skip (duplicates)
const activeFiles = selectedFiles.filter((_, i) => !parsedRows[i]?._skip);
const activeRows = parsedRows.filter(r => !r._skip);
const skippedAsDup = selectedFiles
.filter((f, i) => parsedRows[i]?._skip)
.map(f => ({ file: f.name, reason: 'Duplicate skipped' }));
const total = activeFiles.length;
let done = 0;
const allImported = [];
const allSkipped = [...skippedAsDup];
if (total === 0) {
document.getElementById('progress-status').textContent = 'Done.';
showResult(0, allSkipped);
btn.disabled = false;
return;
}
for (let i = 0; i < activeFiles.length; i += BATCH_SIZE) {
const batchFiles = activeFiles.slice(i, i + BATCH_SIZE);
const batchRows = activeRows.slice(i, i + BATCH_SIZE);
const fd = new FormData();
batchFiles.forEach(f => fd.append('files', f));
fd.append('rows', JSON.stringify(batchRows));
fd.append('shared', JSON.stringify(shared));
try {
const resp = await fetch('/library/bulk-import', { method: 'POST', body: fd });
const data = await resp.json();
if (data.imported) allImported.push(...data.imported);
if (data.skipped) allSkipped.push(...data.skipped);
} catch (e) {
batchFiles.forEach(f => allSkipped.push({ file: f.name, reason: 'Network error' }));
}
done = Math.min(i + BATCH_SIZE, total);
const pct = Math.round((done / total) * 100);
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('progress-status').textContent =
`${done} / ${total} files processed…`;
}
// Done
document.getElementById('progress-status').textContent = 'Done.';
showResult(allImported.length, allSkipped);
btn.disabled = false;
}
function showResult(imported, skipped) {
const box = document.getElementById('result-box');
document.getElementById('result-ok').textContent =
`✓ ${imported} file${imported !== 1 ? 's' : ''} imported.`;
const skippedEl = document.getElementById('result-skipped');
if (skipped.length) {
let html = `<div style="font-family:var(--mono);font-size:0.75rem;color:var(--text-dim);margin-bottom:0.4rem;">${skipped.length} skipped:</div>`;
html += '<div class="skipped-list">';
skipped.forEach(s => {
html += `<div class="skipped-item"><span class="sk-file" title="${esc(s.file)}">${esc(s.file)}</span><span>${esc(s.reason)}</span></div>`;
});
html += '</div>';
skippedEl.innerHTML = html;
} else {
skippedEl.innerHTML = '';
}
box.classList.add('visible');
}
function goToLibrary() {
window.location.href = '/library#new';
}
// ── TextSuggest ────────────────────────────────────────────────────────────
class TextSuggest {
constructor(inputId, dropdownId, onSelect) {
this.input = document.getElementById(inputId);
this.dropdown = document.getElementById(dropdownId);
this.all = [];
this.ddIndex = -1;
this.onSelect = onSelect || (() => {});
this.input.addEventListener('input', () => this._onInput());
this.input.addEventListener('keydown', (e) => this._onKeydown(e));
this.input.addEventListener('blur', () => setTimeout(() => this._hide(), 150));
}
setSuggestions(all) { this.all = all; }
_show(items) {
if (!items.length) { this._hide(); return; }
this.dropdown.innerHTML = items.map(v =>
`<div class="suggest-option" data-val="${v.replace(/"/g,'&quot;')}">${v}</div>`
).join('');
this.dropdown.querySelectorAll('.suggest-option').forEach(el => {
el.onmousedown = (e) => { e.preventDefault(); this.input.value = el.dataset.val; this._hide(); this.onSelect(); };
});
this.dropdown.style.display = 'block';
this.ddIndex = -1;
}
_hide() { this.dropdown.style.display = 'none'; this.ddIndex = -1; }
_onInput() {
const q = this.input.value.trim().toLowerCase();
if (!q) { this._hide(); return; }
this._show(this.all.filter(v => v.toLowerCase().includes(q)).slice(0, 40));
}
_onKeydown(e) {
const opts = this.dropdown.querySelectorAll('.suggest-option');
if (e.key === 'ArrowDown') {
e.preventDefault();
this.ddIndex = Math.min(this.ddIndex + 1, opts.length - 1);
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.ddIndex = Math.max(this.ddIndex - 1, -1);
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
} else if (e.key === 'Enter' && this.ddIndex >= 0 && opts[this.ddIndex]) {
e.preventDefault();
this.input.value = opts[this.ddIndex].dataset.val;
this._hide();
this.onSelect();
} else if (e.key === 'Escape') {
this._hide();
}
}
}
const authorSuggest = new TextSuggest('shared-author', 'author-dropdown', updatePreview);
const publisherSuggest = new TextSuggest('shared-publisher', 'publisher-dropdown', updatePreview);
const seriesSuggest = new TextSuggest('shared-series', 'series-dropdown', updatePreview);
async function loadSuggestions() {
const [authors, publishers, series] = await Promise.all([
fetch('/api/suggestions?type=author').then(r => r.json()),
fetch('/api/suggestions?type=publisher').then(r => r.json()),
fetch('/api/suggestions?type=series').then(r => r.json()),
]);
authorSuggest.setSuggestions(authors);
publisherSuggest.setSuggestions(publishers);
seriesSuggest.setSuggestions(series);
}
// ── Init ───────────────────────────────────────────────────────────────────
initChips();
renderPatternPreview();
loadSuggestions();
</script>
</body>
</html>

View File

@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Changelog</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<style>
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: 'Libre Baskerville', serif; font-size: 15px; }
.main-content {
margin-left: var(--sidebar);
padding: 2.5rem 3rem;
max-width: 860px;
}
.page-header { margin-bottom: 2.5rem; }
.page-header h1 { margin: 0 0 0.25rem; font-size: 1.6rem; font-weight: 700; }
.page-header p { margin: 0; color: var(--text-dim); font-size: 0.85rem; font-family: var(--mono); }
.version-block { margin-bottom: 2.5rem; }
.version-header {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 0.75rem;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--border);
}
.version-tag {
font-family: var(--mono);
font-size: 1rem;
font-weight: 600;
color: var(--accent);
}
.version-date {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-dim);
}
.version-summary {
margin: 0 0 1.25rem;
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1.6;
}
.section { margin-bottom: 1.25rem; }
.section-title {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--mono);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.55rem;
border-radius: 3px;
margin-bottom: 0.6rem;
}
.section-title.feature { background: rgba(74,144,184,0.15); color: #4a90b8; }
.section-title.improvement { background: rgba(107,170,107,0.15); color: var(--success); }
.section-title.bugfix { background: rgba(200,90,58,0.15); color: var(--error); }
.section-title.security { background: rgba(200,160,58,0.15); color: var(--warning); }
.change-list {
margin: 0;
padding: 0 0 0 1.1rem;
list-style: none;
}
.change-list li {
position: relative;
padding: 0.2rem 0 0.2rem 0.9rem;
color: var(--text);
font-size: 0.88rem;
line-height: 1.55;
}
.change-list li::before {
content: '';
position: absolute;
left: 0;
color: var(--text-faint);
}
@media (max-width: 768px) {
.main-content { margin-left: 0; padding: 1.25rem 1rem; }
}
</style>
</head>
<body>
{% include "_sidebar.html" %}
<div class="main-content">
<div class="page-header">
<h1>Changelog</h1>
<p>Release history and updates</p>
</div>
{% for entry in changelog %}
<div class="version-block" id="{{ entry.version }}">
<div class="version-header">
<span class="version-tag">{{ entry.version }}</span>
<span class="version-date">{{ entry.date }}</span>
</div>
{% if entry.summary %}
<p class="version-summary">{{ entry.summary }}</p>
{% endif %}
{% for section in entry.sections %}
<div class="section">
<div class="section-title {{ section.type }}">{{ section.title }}</div>
<ul class="change-list">
{% for change in section.changes %}
<li>{{ change }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</body>
</html>

View File

@ -4,29 +4,15 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Credentials</title> <title>Novela — Credentials</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/> <link rel="stylesheet" href="/static/sidebar.css"/>
<style> <style>
:root {
--bg: #0f0e0c;
--surface: #1a1815;
--surface2: #221f1b;
--border: #2e2a24;
--accent: #c8783a;
--accent2: #e8a063;
--text: #e8e2d9;
--text-dim: #8a8278;
--text-faint: #4a453e;
--success: #6baa6b;
--warning: #c8a03a;
--error: #c85a3a;
--radius: 6px;
--sidebar: 220px;
--mono: 'DM Mono', monospace;
--serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; } html, body { height: 100%; }
@ -295,6 +281,7 @@
</main> </main>
<script src="/static/books.js"></script>
<script> <script>
let allCredentials = {}; let allCredentials = {};

View File

@ -4,29 +4,15 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Debug</title> <title>Novela — Debug</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/> <link rel="stylesheet" href="/static/sidebar.css"/>
<style> <style>
:root {
--bg: #0f0e0c;
--surface: #1a1815;
--surface2: #221f1b;
--border: #2e2a24;
--accent: #c8783a;
--accent2: #e8a063;
--text: #e8e2d9;
--text-dim: #8a8278;
--text-faint: #4a453e;
--success: #6baa6b;
--warning: #c8a03a;
--error: #c85a3a;
--radius: 6px;
--sidebar: 220px;
--mono: 'DM Mono', monospace;
--serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; } html, body { height: 100%; }
@ -178,6 +164,7 @@
</main> </main>
<script src="/static/books.js"></script>
<script> <script>
async function runInspect() { async function runInspect() {
const url = document.getElementById('url').value.trim(); const url = document.getElementById('url').value.trim();
@ -226,10 +213,6 @@
toggle.textContent = collapsed ? '▼ expand' : '▲ collapse'; toggle.textContent = collapsed ? '▼ expand' : '▲ collapse';
} }
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function metaRow(label, value) { function metaRow(label, value) {
return `<div class="meta-row"><span class="meta-label">${label}</span><span class="meta-value">${value}</span></div>`; return `<div class="meta-row"><span class="meta-label">${label}</span><span class="meta-value">${value}</span></div>`;

View File

@ -4,8 +4,13 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Edit {{ title or filename }}</title> <title>Novela — Edit {{ title or filename }}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/editor.css"/> <link rel="stylesheet" href="/static/editor.css"/>
</head> </head>
<body> <body>
@ -19,6 +24,7 @@
{{ (title or filename) | truncate(30, True) }} {{ (title or filename) | truncate(30, True) }}
</a> </a>
<div class="header-chapter" id="header-chapter"></div> <div class="header-chapter" id="header-chapter"></div>
<input class="chapter-title-input" id="chapter-title-input" type="text" placeholder="Chapter title…" style="display:none"/>
<div class="header-actions"> <div class="header-actions">
<button class="btn-add-page" id="btn-add-page" onclick="addChapter()" title="Add new chapter after current"> <button class="btn-add-page" id="btn-add-page" onclick="addChapter()" title="Add new chapter after current">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -89,9 +95,11 @@
const EDITOR = { const EDITOR = {
filename: {{ filename | tojson }}, filename: {{ filename | tojson }},
title: {{ (title or filename) | tojson }}, title: {{ (title or filename) | tojson }},
is_db: {{ is_db | tojson }},
}; };
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script> <script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
<script src="/static/books.js"></script>
<script src="/static/editor.js"></script> <script src="/static/editor.js"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Following</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
.main { margin-left: var(--sidebar); min-height: 100vh; padding: 2rem 2.5rem 4rem; }
@media (max-width: 768px) { .main { margin-left: 0; padding: 4rem 1rem 4rem; } }
.main-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.75rem; flex-wrap: wrap; gap: 1rem;
}
.main-title {
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--accent);
}
.filter-tabs { display: flex; gap: 0.5rem; }
.tab {
font-family: var(--mono); font-size: 0.72rem; padding: 0.3rem 0.75rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text-dim); cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.tab.active { border-color: var(--accent); color: var(--accent); }
.tab .cnt { color: var(--text-faint); margin-left: 0.3rem; }
.author-list { display: flex; flex-direction: column; gap: 0.5rem; max-width: 860px; }
.author-row {
display: flex; align-items: center; gap: 1rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.75rem 1rem;
transition: border-color 0.15s;
}
.author-row:hover { border-color: var(--border); }
.author-row.has-url:hover { border-color: var(--accent); }
.author-avatar {
width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-family: var(--serif); font-size: 0.95rem; font-weight: bold;
}
.author-info { flex: 1; min-width: 0; }
.author-name-link {
font-family: var(--serif); font-size: 0.92rem; color: var(--text);
text-decoration: none;
}
.author-name-link:hover { color: var(--accent2); }
.author-meta {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim);
margin-top: 0.15rem;
}
.author-url-area { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.url-display {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim);
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.url-display a { color: var(--text-dim); text-decoration: none; }
.url-display a:hover { color: var(--accent2); text-decoration: underline; }
.no-url-label {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-faint);
}
.btn-visit {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: transparent; color: var(--text-dim); cursor: pointer;
white-space: nowrap; transition: border-color 0.15s, color 0.15s;
}
.btn-visit:hover { border-color: var(--accent); color: var(--accent); }
.btn-edit {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid transparent; border-radius: var(--radius);
background: transparent; color: var(--text-faint); cursor: pointer;
white-space: nowrap; transition: border-color 0.15s, color 0.15s;
}
.btn-edit:hover { border-color: var(--border); color: var(--text-dim); }
.url-edit-form { display: flex; align-items: center; gap: 0.5rem; }
.url-input {
font-family: var(--mono); font-size: 0.72rem; width: 280px;
background: var(--surface2); border: 1px solid var(--accent);
border-radius: var(--radius); color: var(--text);
padding: 0.25rem 0.5rem; outline: none;
}
@media (max-width: 600px) { .url-input { width: 160px; } }
.btn-save {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--success); border-radius: var(--radius);
background: transparent; color: var(--success); cursor: pointer;
}
.btn-save:hover { background: var(--success); color: var(--bg); }
.btn-cancel {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: transparent; color: var(--text-dim); cursor: pointer;
}
.empty, .loading {
font-family: var(--mono); font-size: 0.8rem; color: var(--text-dim);
padding: 2rem 0;
}
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<div class="main-header">
<div class="main-title">Following</div>
<div class="filter-tabs">
<button id="tab-following" class="tab active" onclick="setFilter('following')">
Following <span class="cnt" id="cnt-following">0</span>
</button>
<button id="tab-all" class="tab" onclick="setFilter('all')">
All Authors <span class="cnt" id="cnt-all">0</span>
</button>
</div>
</div>
<div id="author-list"><div class="loading">Loading…</div></div>
</main>
<script src="/static/books.js"></script>
<script>
function timeAgo(isoStr) {
if (!isoStr) return '';
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';
const diff = Math.floor((Date.now() - new Date(s).getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
if (diff < 2592000) return Math.floor(diff / 604800) + 'w ago';
return Math.floor(diff / 2592000) + 'mo ago';
}
function hostOf(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch (_) { return url; }
}
let allAuthors = [];
let currentFilter = 'following';
async function loadAuthors() {
try {
const resp = await fetch('/api/following');
allAuthors = await resp.json();
} catch (_) {
allAuthors = [];
}
renderList();
updateCounts();
}
function updateCounts() {
const followingCount = allAuthors.filter(a => a.url).length;
document.getElementById('cnt-following').textContent = followingCount;
document.getElementById('cnt-all').textContent = allAuthors.length;
const sidebarEl = document.getElementById('count-following');
if (sidebarEl) sidebarEl.textContent = followingCount || '';
}
function setFilter(f) {
currentFilter = f;
document.getElementById('tab-following').classList.toggle('active', f === 'following');
document.getElementById('tab-all').classList.toggle('active', f === 'all');
renderList();
}
function renderList() {
const container = document.getElementById('author-list');
const items = currentFilter === 'following'
? allAuthors.filter(a => a.url)
: allAuthors;
if (!items.length) {
container.innerHTML = `<div class="empty">${
currentFilter === 'following'
? 'No authors followed yet. Switch to "All Authors" to add URLs.'
: 'No authors in your library yet.'
}</div>`;
return;
}
const list = document.createElement('div');
list.className = 'author-list';
items.forEach(a => list.appendChild(makeRow(a)));
container.innerHTML = '';
container.appendChild(list);
}
function makeRow(author) {
const [bg, fg] = COVER_PALETTES[strHash(author.name) % COVER_PALETTES.length];
const initial = (author.name.trim()[0] || '?').toUpperCase();
const books = author.book_count;
const meta = books + ' book' + (books !== 1 ? 's' : '') + (author.last_added ? ' · ' + timeAgo(author.last_added) : '');
const row = document.createElement('div');
row.className = 'author-row' + (author.url ? ' has-url' : '');
row.dataset.name = author.name;
row.dataset.url = author.url || '';
row.innerHTML = `
<div class="author-avatar" style="background:${bg};color:${fg}">${esc(initial)}</div>
<div class="author-info">
<a class="author-name-link" href="/library#authors/${encodeURIComponent(author.name)}">${esc(author.name)}</a>
<div class="author-meta">${esc(meta)}</div>
</div>
<div class="author-url-area">${urlAreaHtml(author)}</div>`;
return row;
}
function urlAreaHtml(author) {
if (author.url) {
return `<div class="url-display" title="${esc(author.url)}"><a href="${esc(author.url)}" target="_blank" rel="noopener noreferrer">${esc(hostOf(author.url))}</a></div>
<button class="btn-visit" onclick="visitAuthor(this)" title="${esc(author.url)}">↗ Visit</button>
<button class="btn-edit" onclick="startEdit(this)">Edit</button>`;
}
return `<span class="no-url-label"></span>
<button class="btn-edit" onclick="startEdit(this)">+ URL</button>`;
}
function visitAuthor(btn) {
const row = btn.closest('.author-row');
const url = row.dataset.url;
if (url) window.open(url, '_blank', 'noopener,noreferrer');
}
function startEdit(btn) {
const row = btn.closest('.author-row');
const currentUrl = row.dataset.url || '';
const area = row.querySelector('.author-url-area');
area.innerHTML = `
<div class="url-edit-form">
<input class="url-input" type="url" value="${esc(currentUrl)}" placeholder="https://…"/>
<button class="btn-save" onclick="saveUrl(this)">Save</button>
<button class="btn-cancel" onclick="cancelEdit(this)">Cancel</button>
</div>`;
const input = area.querySelector('.url-input');
input.focus();
input.select();
input.addEventListener('keydown', e => {
if (e.key === 'Enter') saveUrl(input.nextElementSibling);
if (e.key === 'Escape') cancelEdit(input.nextElementSibling.nextElementSibling);
});
}
function cancelEdit(btn) {
const row = btn.closest('.author-row');
const name = row.dataset.name;
const author = allAuthors.find(a => a.name === name);
if (!author) return;
row.querySelector('.author-url-area').innerHTML = urlAreaHtml(author);
}
async function saveUrl(btn) {
const row = btn.closest('.author-row');
const name = row.dataset.name;
const input = row.querySelector('.url-input');
const url = (input ? input.value : '').trim();
btn.disabled = true;
try {
const resp = await fetch('/api/following/' + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!resp.ok) throw new Error('Failed');
} catch (_) {
alert('Failed to save URL.');
if (btn) btn.disabled = false;
return;
}
const author = allAuthors.find(a => a.name === name);
if (author) author.url = url || null;
row.dataset.url = url;
row.className = 'author-row' + (url ? ' has-url' : '');
row.querySelector('.author-url-area').innerHTML = urlAreaHtml(author || { url: url || null });
updateCounts();
if (currentFilter === 'following' && !url) {
row.remove();
const list = document.querySelector('#author-list .author-list');
if (list && !list.children.length) {
document.getElementById('author-list').innerHTML =
'<div class="empty">No authors followed yet. Switch to "All Authors" to add URLs.</div>';
}
}
}
loadAuthors();
</script>
</body>
</html>

View File

@ -4,29 +4,15 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela</title> <title>Novela</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/> <link rel="stylesheet" href="/static/sidebar.css"/>
<style> <style>
:root {
--bg: #0f0e0c;
--surface: #1a1815;
--surface2: #221f1b;
--border: #2e2a24;
--accent: #c8783a;
--accent2: #e8a063;
--text: #e8e2d9;
--text-dim: #8a8278;
--text-faint: #4a453e;
--success: #6baa6b;
--warning: #c8a03a;
--error: #c85a3a;
--radius: 6px;
--sidebar: 220px;
--mono: 'DM Mono', monospace;
--serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; } html, body { height: 100%; }
@ -227,6 +213,34 @@
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.btn-outline:hover { background: var(--surface); color: var(--text); border-color: var(--text-faint); } .btn-outline:hover { background: var(--surface); color: var(--text); border-color: var(--text-faint); }
.storage-toggle {
display: flex; align-items: center; gap: 0.5rem;
margin-bottom: 1rem;
}
.storage-label {
font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim);
}
.storage-opt {
width: auto; padding: 0.3rem 0.75rem;
background: var(--surface2); color: var(--text-dim);
border: 1px solid var(--border); font-size: 0.75rem;
}
.storage-opt:first-of-type { border-radius: var(--radius) 0 0 var(--radius); }
.storage-opt:last-of-type { border-radius: 0 var(--radius) var(--radius) 0; }
.storage-opt.active { background: var(--accent); color: #0f0e0c; border-color: var(--accent); }
.storage-opt:hover:not(.active) { background: var(--surface); color: var(--text); }
.dup-warning {
display: none; width: 100%; max-width: 620px; margin-bottom: 1.5rem;
background: rgba(200,160,58,0.08); border: 1px solid rgba(200,160,58,0.35);
border-radius: var(--radius); padding: 0.85rem 1rem;
font-family: var(--mono); font-size: 0.78rem; color: var(--warning);
line-height: 1.6;
}
.dup-warning.visible { display: block; }
.dup-warning a { color: var(--accent2); text-decoration: none; }
.dup-warning a:hover { text-decoration: underline; }
</style> </style>
</head> </head>
<body> <body>
@ -239,7 +253,7 @@
<div class="card"> <div class="card">
<div class="card-title">Book URL</div> <div class="card-title">Book URL</div>
<label for="url">Story overview page</label> <label for="url">Story overview page</label>
<input type="url" id="url" placeholder="https://..." oninput="checkUrlCredentials()"/> <input type="url" id="url" placeholder="https://..." oninput="checkUrlCredentials(); clearDupWarning()"/>
<div class="cred-status" id="cred-status"></div> <div class="cred-status" id="cred-status"></div>
<button id="load-btn" onclick="loadMeta()"> <button id="load-btn" onclick="loadMeta()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
@ -250,6 +264,9 @@
</button> </button>
</div> </div>
<!-- Duplicate warning -->
<div class="dup-warning" id="dup-warning"></div>
<!-- Step 2: Metadata preview + cover upload + Convert --> <!-- Step 2: Metadata preview + cover upload + Convert -->
<div class="card" id="meta-card"> <div class="card" id="meta-card">
<div class="card-title">Book info</div> <div class="card-title">Book info</div>
@ -268,6 +285,12 @@
<div class="cover-filename" id="cover-filename"></div> <div class="cover-filename" id="cover-filename"></div>
</div> </div>
<div class="storage-toggle">
<span class="storage-label">Save as</span>
<button type="button" class="storage-opt active" id="opt-db" onclick="setStorage('db')">DB</button>
<button type="button" class="storage-opt" id="opt-epub" onclick="setStorage('epub')">EPUB file</button>
</div>
<button id="convert-btn" onclick="startConvert()"> <button id="convert-btn" onclick="startConvert()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 12h14M12 5l7 7-7 7"/> <path d="M5 12h14M12 5l7 7-7 7"/>
@ -297,7 +320,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/> <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg> </svg>
Download EPUB <span>Download EPUB</span>
</button> </button>
<button class="btn-outline" id="book-detail-btn"> <button class="btn-outline" id="book-detail-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
@ -310,9 +333,18 @@
</main> </main>
<script src="/static/books.js"></script>
<script src="/static/conversion.js"></script>
<script> <script>
let currentUrl = ''; let currentUrl = '';
let coverB64 = null; let coverB64 = null;
let storageMode = 'db';
function setStorage(mode) {
storageMode = mode;
document.getElementById('opt-db').classList.toggle('active', mode === 'db');
document.getElementById('opt-epub').classList.toggle('active', mode === 'epub');
}
// --- Credential status --- // --- Credential status ---
async function checkUrlCredentials() { async function checkUrlCredentials() {
@ -362,6 +394,7 @@
} }
renderMeta(d); renderMeta(d);
showDupWarning(d.already_exists ? d.existing_books : []);
document.getElementById('meta-card').classList.add('visible'); document.getElementById('meta-card').classList.add('visible');
// Reset cover upload // Reset cover upload
document.getElementById('cover-file').value = ''; document.getElementById('cover-file').value = '';
@ -455,7 +488,7 @@
document.getElementById('log-lines').innerHTML = ''; document.getElementById('log-lines').innerHTML = '';
document.getElementById('progress-bar').style.width = '0%'; document.getElementById('progress-bar').style.width = '0%';
const body = { url: currentUrl }; const body = { url: currentUrl, storage_mode: storageMode };
if (coverB64) body.cover_b64 = coverB64; if (coverB64) body.cover_b64 = coverB64;
const seriesInput = document.getElementById('series-index-input'); const seriesInput = document.getElementById('series-index-input');
if (seriesInput) body.series_index = parseInt(seriesInput.value) || 1; if (seriesInput) body.series_index = parseInt(seriesInput.value) || 1;
@ -472,91 +505,25 @@
document.getElementById('convert-label').textContent = 'Convert'; document.getElementById('convert-label').textContent = 'Convert';
document.getElementById('convert-spinner').style.display = 'none'; document.getElementById('convert-spinner').style.display = 'none';
const es = new EventSource(`/events/${job_id}`); connectConversionStream(job_id);
es.addEventListener('status', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = d.message;
addLog(d.message);
});
es.addEventListener('meta', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = `"${d.title}" by ${d.author}`;
});
es.addEventListener('chapters', e => {
const d = JSON.parse(e.data);
const ul = document.getElementById('chapter-list');
ul.innerHTML = '';
d.chapters.forEach((title, i) => {
const li = document.createElement('li');
li.className = 'chapter-item';
li.id = `ch-${i}`;
li.innerHTML = `<span class="dot"></span><span>${esc(title)}</span>`;
ul.appendChild(li);
});
});
es.addEventListener('progress', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width =
Math.round((d.current / d.total) * 100) + '%';
document.getElementById('status-line').textContent =
`Chapter ${d.current} of ${d.total}: ${d.title}`;
if (d.current > 1) {
const prev = document.getElementById(`ch-${d.current - 2}`);
if (prev) prev.className = 'chapter-item done';
}
const cur = document.getElementById(`ch-${d.current - 1}`);
if (cur) { cur.className = 'chapter-item active'; cur.scrollIntoView({ block: 'nearest' }); }
});
es.addEventListener('warning', e => {
addLog(JSON.parse(e.data).message, 'warn');
});
es.addEventListener('error', e => {
const d = JSON.parse(e.data);
addLog(d.message, 'err');
document.getElementById('status-line').textContent = '❌ ' + d.message;
document.getElementById('convert-btn').disabled = false;
es.close();
});
es.addEventListener('done', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width = '100%';
document.getElementById('status-line').textContent = 'Done ✓';
document.querySelectorAll('.chapter-item').forEach(el => el.className = 'chapter-item done');
document.getElementById('result-meta').innerHTML =
`<strong>${esc(d.title)}</strong><br/>${d.chapters} chapters successfully converted`;
document.getElementById('download-btn').onclick = () => {
window.location = `/download/${encodeURIComponent(d.filename)}`;
};
document.getElementById('book-detail-btn').onclick = () => {
window.location = `/library/book/${encodeURIComponent(d.filename)}`;
};
document.getElementById('result-card').classList.add('visible');
document.getElementById('convert-btn').disabled = false;
es.close();
});
} }
function addLog(msg, cls) { function clearDupWarning() {
const div = document.getElementById('log-lines'); const el = document.getElementById('dup-warning');
const span = document.createElement('span'); el.classList.remove('visible');
if (cls) span.className = cls; el.innerHTML = '';
span.textContent = msg;
span.style.display = 'block';
div.appendChild(span);
div.scrollTop = div.scrollHeight;
} }
function esc(s) { function showDupWarning(books) {
return String(s ?? '') const el = document.getElementById('dup-warning');
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); if (!books || !books.length) { clearDupWarning(); return; }
const links = books.map(b =>
`<a href="/library/book/${encodeURIComponent(b.filename)}" target="_blank">${esc(b.title)}</a>`
).join(', ');
el.innerHTML = `⚠ This title already exists in your library: ${links}. You can still proceed with the conversion.`;
el.classList.add('visible');
} }
</script> </script>
</body> </body>
</html> </html>

View File

@ -4,18 +4,15 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Home</title> <title>Novela — Home</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/> <link rel="stylesheet" href="/static/sidebar.css"/>
<style> <style>
:root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a; --accent2: #e8a063;
--text: #e8e2d9; --text-dim: #8a8278; --text-faint: #4a453e;
--success: #6baa6b; --warning: #c8a03a; --error: #c85a3a;
--radius: 6px; --sidebar: 220px;
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); } html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
@ -66,7 +63,7 @@
.import-dropzone:hover { border-color: var(--accent); } .import-dropzone:hover { border-color: var(--accent); }
.import-dropzone.dragover { .import-dropzone.dragover {
border-color: var(--accent2); border-color: var(--accent2);
background: rgba(200, 120, 58, 0.12); background: rgba(255, 162, 14, 0.12);
} }
.import-dropzone.uploading { .import-dropzone.uploading {
opacity: 0.8; opacity: 0.8;
@ -130,7 +127,7 @@
font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim); font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.h-progress-bar { height: 3px; background: rgba(200,120,58,0.2); border-radius: 2px; margin-bottom: 0.25rem; } .h-progress-bar { height: 3px; background: rgba(255,162,14,0.2); border-radius: 2px; margin-bottom: 0.25rem; }
.h-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; } .h-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; }
.h-pct { font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim); } .h-pct { font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim); }
@ -163,7 +160,7 @@
.progress-mini { .progress-mini {
position: absolute; bottom: 0; left: 0; right: 0; position: absolute; bottom: 0; left: 0; right: 0;
height: 3px; z-index: 2; pointer-events: none; height: 3px; z-index: 2; pointer-events: none;
background: rgba(200,120,58,0.25); background: rgba(255,162,14,0.25);
} }
.progress-mini-fill { height: 100%; background: var(--accent); } .progress-mini-fill { height: 100%; background: var(--accent); }
.book-info { padding: 0.5rem 0.2rem 0; } .book-info { padding: 0.5rem 0.2rem 0; }
@ -175,6 +172,11 @@
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim); font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
margin-top: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.star-row { display: flex; gap: 0.1rem; margin-top: 0.3rem; padding: 0 0.1rem; }
.star { font-size: 0.72rem; color: rgba(200, 160, 58, 0.25); cursor: default; line-height: 1; transition: color 0.1s; user-select: none; }
.star.filled { color: var(--warning); }
.star-row.interactive .star { cursor: pointer; }
.star-row.interactive:hover .star { color: var(--accent2); }
.empty { .empty {
text-align: center; color: var(--text-faint); font-family: var(--mono); text-align: center; color: var(--text-faint); font-family: var(--mono);
@ -282,6 +284,7 @@
</div> </div>
</main> </main>
<script src="/static/books.js"></script>
<script> <script>
let data = { continue_reading: [], shorts_unread: [], novels_unread: [], shorts_read: [], novels_read: [] }; let data = { continue_reading: [], shorts_unread: [], novels_unread: [], shorts_read: [], novels_read: [] };
let currentView = 'home'; let currentView = 'home';
@ -290,54 +293,36 @@
let allBooks = []; let allBooks = [];
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz']; const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
function strHash(s) { function jsEsc(s) { return String(s || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
const PALETTES = [
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],['#2a1a2a','#8a4aaa'],
['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
];
function makePlaceholder(canvas, title, author) {
const w = canvas.width = canvas.offsetWidth || 150;
const h = canvas.height = canvas.offsetHeight || 225;
const ctx = canvas.getContext('2d');
const [bg, fg] = PALETTES[strHash(title) % PALETTES.length];
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = fg; ctx.globalAlpha = 0.15; ctx.fillRect(0, 0, w, h * 0.08); ctx.globalAlpha = 1;
ctx.fillStyle = fg; ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
ctx.fillStyle = '#e8e2d9';
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
ctx.textAlign = 'center';
wrapText(ctx, title, w * 0.55, h * 0.28, w * 0.72, Math.round(w * 0.12));
ctx.fillStyle = fg; ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
ctx.globalAlpha = 0.85; ctx.fillText(trunc(author, 18), w * 0.55, h * 0.86); ctx.globalAlpha = 1;
}
function wrapText(ctx, text, x, y, maxW, lineH) {
const words = text.split(' '); let line = '', lines = [];
for (const w of words) {
const t = line ? line + ' ' + w : w;
if (ctx.measureText(t).width > maxW && line) { lines.push(line); line = w; } else line = t;
}
if (line) lines.push(line); lines = lines.slice(0, 4);
const startY = y - ((lines.length - 1) * lineH) / 2;
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
}
function trunc(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); } function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); }
function filenameBase(filename) {
const leaf = String(filename || '').split('/').pop() || ''; function starsHtml(filename, rating) {
return leaf.replace(/\.[^.]+$/, ''); const r = rating || 0;
const id = cssId(filename);
let html = `<div class="star-row" id="hstars-${id}">`;
for (let i = 1; i <= 5; i++) {
html += `<span class="star ${i <= r ? 'filled' : ''}"></span>`;
}
html += '</div>';
return html;
} }
function bookTitle(b) { return b.title || (filenameBase(b.filename).split('-')[2] ?? '').replace(/_/g, ' '); }
function bookAuthor(b) { async function rateBook(filename, rating) {
if (b.author) return b.author; const book = allBooks.find(b => b.filename === filename);
return (filenameBase(b.filename).split('-')[1] ?? '').replace(/_/g, ' '); const newRating = (book && book.rating === rating) ? 0 : rating;
try {
const resp = await fetch(`/library/rating/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: newRating }),
});
const result = await resp.json();
if (!resp.ok || result.error) return;
if (book) book.rating = result.rating;
const id = cssId(filename);
const row = document.getElementById(`hstars-${id}`);
if (row) row.outerHTML = starsHtml(filename, result.rating);
} catch {}
} }
function attachCover(coverEl, canvasEl, b) { function attachCover(coverEl, canvasEl, b) {
@ -349,11 +334,11 @@
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`; img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
img.alt = title; img.alt = title;
img.onload = () => { canvasEl.style.display = 'none'; }; img.onload = () => { canvasEl.style.display = 'none'; };
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvasEl, title, author)); img.onerror = () => requestAnimationFrame(() => makePlaceholderCover(canvasEl, title, author));
coverEl.style.position = 'relative'; coverEl.style.position = 'relative';
coverEl.insertBefore(img, coverEl.firstChild); coverEl.insertBefore(img, coverEl.firstChild);
} }
requestAnimationFrame(() => makePlaceholder(canvasEl, title, author)); requestAnimationFrame(() => makePlaceholderCover(canvasEl, title, author));
} }
function makeHCard(b, showProgress) { function makeHCard(b, showProgress) {
@ -368,6 +353,7 @@
<div class="h-cover" id="hc-${id}"> <div class="h-cover" id="hc-${id}">
<canvas id="hcv-${id}" style="width:100%;height:100%;display:block"></canvas> <canvas id="hcv-${id}" style="width:100%;height:100%;display:block"></canvas>
</div> </div>
${starsHtml(b.filename, b.rating)}
<div class="h-info"> <div class="h-info">
<div class="h-title">${esc(title)}</div> <div class="h-title">${esc(title)}</div>
${showProgress ${showProgress
@ -393,6 +379,7 @@
: ''} : ''}
</a> </a>
</div> </div>
${starsHtml(b.filename, b.rating)}
<div class="book-info"> <div class="book-info">
<div class="book-title">${esc(title)}</div> <div class="book-title">${esc(title)}</div>
<div class="book-author">${esc(author)}</div> <div class="book-author">${esc(author)}</div>
@ -430,22 +417,16 @@
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`; img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
img.alt = bookTitle(b); img.alt = bookTitle(b);
img.onload = () => { canvas.style.display = 'none'; }; img.onload = () => { canvas.style.display = 'none'; };
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b))); img.onerror = () => requestAnimationFrame(() => makePlaceholderCover(canvas, bookTitle(b), bookAuthor(b)));
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover'; img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
canvas.parentElement.insertBefore(img, canvas); canvas.parentElement.insertBefore(img, canvas);
} }
requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b))); requestAnimationFrame(() => makePlaceholderCover(canvas, bookTitle(b), bookAuthor(b)));
}); });
} }
function searchResults(query) { function searchResults(query) {
const q = String(query || '').trim().toLowerCase(); return filterBooks(allBooks, query);
if (!q) return [];
return allBooks.filter(b =>
bookTitle(b).toLowerCase().includes(q) ||
bookAuthor(b).toLowerCase().includes(q) ||
(b.genres || []).some(g => String(g || '').toLowerCase().includes(q))
);
} }
function switchView(view) { function switchView(view) {
@ -491,16 +472,9 @@
} }
function setupSearch() { function setupSearch() {
const input = document.getElementById('home-search-input'); setupSearchInput('home-search-input', 'home-search-clear', q => {
const clear = document.getElementById('home-search-clear'); if (q) switchView('search');
input.addEventListener('input', () => { else if (currentView === 'search') switchView('home');
const q = input.value.trim();
clear.style.display = q ? '' : 'none';
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
if (q) switchView('search');
else if (currentView === 'search') switchView('home');
}, 180);
}); });
} }

View File

@ -4,29 +4,15 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela</title> <title>Novela</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/> <link rel="stylesheet" href="/static/sidebar.css"/>
<style> <style>
:root {
--bg: #0f0e0c;
--surface: #1a1815;
--surface2: #221f1b;
--border: #2e2a24;
--accent: #c8783a;
--accent2: #e8a063;
--text: #e8e2d9;
--text-dim: #8a8278;
--text-faint: #4a453e;
--success: #6baa6b;
--warning: #c8a03a;
--error: #c85a3a;
--radius: 6px;
--sidebar: 220px;
--mono: 'DM Mono', monospace;
--serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; } html, body { height: 100%; }
@ -310,6 +296,8 @@
</main> </main>
<script src="/static/books.js"></script>
<script src="/static/conversion.js"></script>
<script> <script>
let currentUrl = ''; let currentUrl = '';
let coverB64 = null; let coverB64 = null;
@ -472,91 +460,9 @@
document.getElementById('convert-label').textContent = 'Convert'; document.getElementById('convert-label').textContent = 'Convert';
document.getElementById('convert-spinner').style.display = 'none'; document.getElementById('convert-spinner').style.display = 'none';
const es = new EventSource(`/events/${job_id}`); connectConversionStream(job_id);
es.addEventListener('status', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = d.message;
addLog(d.message);
});
es.addEventListener('meta', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = `"${d.title}" by ${d.author}`;
});
es.addEventListener('chapters', e => {
const d = JSON.parse(e.data);
const ul = document.getElementById('chapter-list');
ul.innerHTML = '';
d.chapters.forEach((title, i) => {
const li = document.createElement('li');
li.className = 'chapter-item';
li.id = `ch-${i}`;
li.innerHTML = `<span class="dot"></span><span>${esc(title)}</span>`;
ul.appendChild(li);
});
});
es.addEventListener('progress', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width =
Math.round((d.current / d.total) * 100) + '%';
document.getElementById('status-line').textContent =
`Chapter ${d.current} of ${d.total}: ${d.title}`;
if (d.current > 1) {
const prev = document.getElementById(`ch-${d.current - 2}`);
if (prev) prev.className = 'chapter-item done';
}
const cur = document.getElementById(`ch-${d.current - 1}`);
if (cur) { cur.className = 'chapter-item active'; cur.scrollIntoView({ block: 'nearest' }); }
});
es.addEventListener('warning', e => {
addLog(JSON.parse(e.data).message, 'warn');
});
es.addEventListener('error', e => {
const d = JSON.parse(e.data);
addLog(d.message, 'err');
document.getElementById('status-line').textContent = '❌ ' + d.message;
document.getElementById('convert-btn').disabled = false;
es.close();
});
es.addEventListener('done', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width = '100%';
document.getElementById('status-line').textContent = 'Done ✓';
document.querySelectorAll('.chapter-item').forEach(el => el.className = 'chapter-item done');
document.getElementById('result-meta').innerHTML =
`<strong>${esc(d.title)}</strong><br/>${d.chapters} chapters successfully converted`;
document.getElementById('download-btn').onclick = () => {
window.location = `/download/${encodeURIComponent(d.filename)}`;
};
document.getElementById('book-detail-btn').onclick = () => {
window.location = `/library/book/${encodeURIComponent(d.filename)}`;
};
document.getElementById('result-card').classList.add('visible');
document.getElementById('convert-btn').disabled = false;
es.close();
});
} }
function addLog(msg, cls) {
const div = document.getElementById('log-lines');
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = msg;
span.style.display = 'block';
div.appendChild(span);
div.scrollTop = div.scrollHeight;
}
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
</script> </script>
</body> </body>
</html> </html>

View File

@ -4,8 +4,13 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Library</title> <title>Novela — Library</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/> <link rel="stylesheet" href="/static/sidebar.css"/>
<link rel="stylesheet" href="/static/library.css"/> <link rel="stylesheet" href="/static/library.css"/>
</head> </head>
@ -36,6 +41,7 @@
<div class="import-title">Drop EPUB, PDF or CBR/CBZ files here</div> <div class="import-title">Drop EPUB, PDF or CBR/CBZ files here</div>
<div class="import-sub">or click to choose files</div> <div class="import-sub">or click to choose files</div>
</div> </div>
<div id="all-controls" class="new-controls" style="display:none"></div>
<div id="new-controls" class="new-controls" style="display:none"></div> <div id="new-controls" class="new-controls" style="display:none"></div>
<div id="grid-container"> <div id="grid-container">
<div class="empty">Loading…</div> <div class="empty">Loading…</div>
@ -54,6 +60,24 @@
</div> </div>
</div> </div>
<!-- Bulk delete dialog -->
<div class="overlay" id="bulk-delete-overlay">
<div class="dialog">
<div class="dialog-title del">Delete books</div>
<p>Delete <strong id="bulk-delete-count"></strong> selected book(s)?<br/>Files will be permanently removed from disk. This cannot be undone.</p>
<div id="bulk-delete-progress" style="display:none;margin-bottom:1rem">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:100px;height:5px;overflow:hidden;margin-bottom:0.5rem">
<div id="bulk-delete-bar" style="height:100%;background:var(--error);border-radius:100px;width:0%;transition:width 0.2s ease"></div>
</div>
<div id="bulk-delete-status" style="font-family:var(--mono);font-size:0.75rem;color:var(--text-dim)"></div>
</div>
<div class="dialog-actions" id="bulk-delete-actions">
<button class="btn btn-cancel" onclick="closeBulkDeleteDialog()">Cancel</button>
<button class="btn btn-confirm-del" id="bulk-delete-btn" onclick="confirmBulkDelete()">Delete</button>
</div>
</div>
</div>
<!-- Add cover dialog --> <!-- Add cover dialog -->
<div class="overlay" id="cover-overlay"> <div class="overlay" id="cover-overlay">
<div class="dialog"> <div class="dialog">
@ -74,6 +98,7 @@
</div> </div>
</div> </div>
<script src="/static/books.js"></script>
<script src="/static/library.js"></script> <script src="/static/library.js"></script>
</body> </body>
</html> </html>

View File

@ -4,15 +4,15 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — {{ title }}</title> <title>Novela — {{ title }}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<style> <style>
:root { :root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a; --text: #e8e2d9;
--text-dim: #8a8278; --text-faint: #4a453e; --success: #6baa6b;
--radius: 6px;
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
--header-h: 50px; --footer-h: 36px; --header-h: 50px; --footer-h: 36px;
--content-w: 65vw; --content-w: 65vw;
} }
@ -42,6 +42,7 @@
color: var(--text-dim); text-decoration: none; color: var(--text-dim); text-decoration: none;
display: flex; align-items: center; gap: 0.35rem; display: flex; align-items: center; gap: 0.35rem;
flex-shrink: 0; flex-shrink: 0;
margin-left: 1rem;
transition: color 0.12s; transition: color 0.12s;
} }
.header-back:hover { color: var(--text); } .header-back:hover { color: var(--text); }
@ -66,6 +67,58 @@
.btn-header:hover { color: var(--text); border-color: var(--text-faint); } .btn-header:hover { color: var(--text); border-color: var(--text-faint); }
.btn-header-read { color: var(--success); border-color: rgba(107,170,107,0.3); } .btn-header-read { color: var(--success); border-color: rgba(107,170,107,0.3); }
.btn-header-read:hover { background: rgba(107,170,107,0.08); border-color: var(--success); } .btn-header-read:hover { background: rgba(107,170,107,0.08); border-color: var(--success); }
.btn-header-bm { color: var(--accent); border-color: rgba(255,162,14,0.3); }
.btn-header-bm:hover { background: rgba(255,162,14,0.08); border-color: var(--accent); }
/* ── Bookmark modal ── */
.bm-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.55); z-index: 300;
align-items: center; justify-content: center;
}
.bm-overlay.open { display: flex; }
.bm-modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.4rem 1.5rem;
width: min(420px, 92vw);
}
.bm-title {
font-family: var(--mono); font-size: 0.7rem;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--accent); margin-bottom: 1rem;
}
.bm-chapter {
font-family: var(--mono); font-size: 0.72rem;
color: var(--text-dim); margin-bottom: 1rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.bm-label {
font-family: var(--mono); font-size: 0.7rem;
color: var(--text-dim); margin-bottom: 0.4rem; display: block;
}
.bm-textarea {
width: 100%; min-height: 90px;
background: var(--surface2); border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--mono); font-size: 0.78rem;
color: var(--text); padding: 0.55rem 0.75rem;
resize: vertical; line-height: 1.5;
margin-bottom: 1rem;
}
.bm-textarea:focus { outline: none; border-color: var(--accent); }
.bm-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
.bm-btn {
padding: 0.4rem 1rem;
border-radius: var(--radius);
font-family: var(--mono); font-size: 0.72rem;
cursor: pointer; border: 1px solid var(--border);
}
.bm-btn-cancel { background: none; color: var(--text-dim); }
.bm-btn-cancel:hover { border-color: var(--text-faint); color: var(--text); }
.bm-btn-save { background: var(--accent); color: #fff; border-color: var(--accent); }
.bm-btn-save:hover { filter: brightness(1.1); }
/* ── Settings drawer ── */ /* ── Settings drawer ── */
.settings-overlay { .settings-overlay {
@ -107,6 +160,18 @@
background: transparent; cursor: pointer; background: transparent; cursor: pointer;
height: 4px; height: 4px;
} }
.colour-swatches {
display: flex; gap: 0.55rem; align-items: center; flex-wrap: wrap;
}
.colour-swatch {
width: 24px; height: 24px; border-radius: 50%;
border: 2px solid transparent;
cursor: pointer; transition: border-color 0.12s, transform 0.1s;
box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
padding: 0;
}
.colour-swatch:hover { transform: scale(1.15); }
.colour-swatch.active { border-color: var(--accent); }
/* ── Viewer ── */ /* ── Viewer ── */
#viewer { #viewer {
@ -213,6 +278,20 @@
</head> </head>
<body> <body>
<!-- Bookmark modal -->
<div class="bm-overlay" id="bm-overlay">
<div class="bm-modal">
<div class="bm-title">Add bookmark</div>
<div class="bm-chapter" id="bm-chapter"></div>
<label class="bm-label" for="bm-note">Note (optional)</label>
<textarea class="bm-textarea" id="bm-note" placeholder="What do you want to remember here?"></textarea>
<div class="bm-actions">
<button class="bm-btn bm-btn-cancel" onclick="closeBookmarkModal()">Cancel</button>
<button class="bm-btn bm-btn-save" onclick="saveBookmark()">Save bookmark</button>
</div>
</div>
</div>
<!-- Loading --> <!-- Loading -->
<div id="loading"> <div id="loading">
<div class="spinner"></div> <div class="spinner"></div>
@ -231,6 +310,16 @@
<input type="range" id="width-slider" min="30" max="100" step="1" <input type="range" id="width-slider" min="30" max="100" step="1"
value="65" oninput="applyWidth(this.value)"/> value="65" oninput="applyWidth(this.value)"/>
</div> </div>
<div class="settings-row">
<div class="settings-label">Text colour</div>
<div class="colour-swatches">
<button class="colour-swatch" data-colour="#e8e2d9" title="Bright" style="background:#e8e2d9" onclick="applyTextColour('#e8e2d9')"></button>
<button class="colour-swatch" data-colour="#d4cec5" title="Warm cream" style="background:#d4cec5" onclick="applyTextColour('#d4cec5')"></button>
<button class="colour-swatch" data-colour="#bfb8ae" title="Soft sand" style="background:#bfb8ae" onclick="applyTextColour('#bfb8ae')"></button>
<button class="colour-swatch" data-colour="#a9a29a" title="Muted" style="background:#a9a29a" onclick="applyTextColour('#a9a29a')"></button>
<button class="colour-swatch" data-colour="#938d86" title="Dim" style="background:#938d86" onclick="applyTextColour('#938d86')"></button>
</div>
</div>
</div> </div>
<!-- Header --> <!-- Header -->
@ -250,6 +339,12 @@
</a> </a>
<div class="header-title" id="header-title"></div> <div class="header-title" id="header-title"></div>
<div class="header-actions"> <div class="header-actions">
<button class="btn-header btn-header-bm" onclick="openBookmarkModal()" title="Add bookmark at current position">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
Bookmark
</button>
<button class="btn-header btn-header-read" onclick="markRead()"> <button class="btn-header btn-header-read" onclick="markRead()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/> <polyline points="20 6 9 17 4 12"/>
@ -287,8 +382,10 @@
<div class="footer-pct" id="footer-pct">0%</div> <div class="footer-pct" id="footer-pct">0%</div>
</div> </div>
<script src="/static/books.js"></script>
<script> <script>
const filename = {{ filename | tojson }}; const filename = {{ filename | tojson }};
const FORMAT = {{ format | tojson }};
let chapters = []; let chapters = [];
let currentIndex = 0; let currentIndex = 0;
@ -309,6 +406,20 @@
applyWidth(saved); applyWidth(saved);
} }
// ── Text colour ────────────────────────────────────────────────
function applyTextColour(hex) {
document.documentElement.style.setProperty('--text', hex);
localStorage.setItem('reader-text-colour', hex);
document.querySelectorAll('.colour-swatch').forEach(el => {
el.classList.toggle('active', el.dataset.colour === hex);
});
}
function loadTextColour() {
const saved = localStorage.getItem('reader-text-colour') || '#e8e2d9';
applyTextColour(saved);
}
// ── Settings drawer ──────────────────────────────────────────── // ── Settings drawer ────────────────────────────────────────────
function toggleSettings() { function toggleSettings() {
const open = document.getElementById('settings-drawer').classList.toggle('open'); const open = document.getElementById('settings-drawer').classList.toggle('open');
@ -320,20 +431,20 @@
document.getElementById('settings-overlay').classList.remove('open'); document.getElementById('settings-overlay').classList.remove('open');
} }
// ── Progress (chapter + scroll within chapter) ───────────────── const IS_PAGED = (FORMAT === 'pdf' || FORMAT === 'cbr' || FORMAT === 'cbz');
// ── Progress ───────────────────────────────────────────────────
function calcProgress() { function calcProgress() {
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
const scrollFrac = maxScroll > 0
? Math.min(1, window.scrollY / maxScroll)
: 0;
// Multi-chapter: (chapterIndex + scrollFrac) / (total - 1) × 100
// Single-chapter: use scroll position only so it doesn't start at 100%.
const total = chapters.length; const total = chapters.length;
const pct = total > 1 if (IS_PAGED) {
? Math.round(((currentIndex + scrollFrac) / (total - 1)) * 100) const pct = total > 0 ? Math.round(((currentIndex + 1) / total) * 100) : 0;
: total === 1 return { scrollFrac: 0, pct };
? Math.round(scrollFrac * 100) }
: 0; const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
const scrollFrac = maxScroll > 0 ? Math.min(1, window.scrollY / maxScroll) : 0;
const pct = total > 0
? Math.min(100, Math.round(((currentIndex + scrollFrac) / total) * 100))
: 0;
return { scrollFrac, pct }; return { scrollFrac, pct };
} }
@ -358,7 +469,55 @@
}, 1000); }, 1000);
} }
// ── Chapter loading ──────────────────────────────────────────── // ── PDF page loading ───────────────────────────────────────────
async function loadPdfPage(index, saveProgress) {
if (index < 0 || index >= chapters.length) return;
currentIndex = index;
const content = document.getElementById('chapter-content');
content.innerHTML =
`<div style="text-align:center">` +
`<img src="/library/pdf/${encodeURIComponent(filename)}?page=${index}&dpi=150"` +
` style="max-width:100%;height:auto;border-radius:4px" alt="Page ${index + 1}"/>` +
`</div>`;
window.scrollTo(0, 0);
document.getElementById('header-title').innerHTML =
`<strong>Page ${index + 1} / ${chapters.length}</strong>`;
document.getElementById('btn-prev').disabled = index === 0;
document.getElementById('btn-next').disabled = index === chapters.length - 1;
document.getElementById('chapter-nav-label').textContent =
`${index + 1} / ${chapters.length}`;
updateFooter();
if (saveProgress) scheduleSave();
}
// ── CBR/CBZ page loading ───────────────────────────────────────
async function loadCbrPage(index, saveProgress) {
if (index < 0 || index >= chapters.length) return;
currentIndex = index;
const content = document.getElementById('chapter-content');
content.innerHTML =
`<div style="text-align:center">` +
`<img src="/library/cbr/${encodeURIComponent(filename)}?page=${index}"` +
` style="max-width:100%;height:auto;border-radius:4px" alt="Page ${index + 1}"/>` +
`</div>`;
window.scrollTo(0, 0);
document.getElementById('header-title').innerHTML =
`<strong>Page ${index + 1} / ${chapters.length}</strong>`;
document.getElementById('btn-prev').disabled = index === 0;
document.getElementById('btn-next').disabled = index === chapters.length - 1;
document.getElementById('chapter-nav-label').textContent =
`${index + 1} / ${chapters.length}`;
updateFooter();
if (saveProgress) scheduleSave();
}
// ── EPUB chapter loading ───────────────────────────────────────
async function loadChapter(index, saveProgress, scrollFrac) { async function loadChapter(index, saveProgress, scrollFrac) {
if (index < 0 || index >= chapters.length) return; if (index < 0 || index >= chapters.length) return;
currentIndex = index; currentIndex = index;
@ -367,7 +526,6 @@
const html = await resp.text(); const html = await resp.text();
document.getElementById('chapter-content').innerHTML = html; document.getElementById('chapter-content').innerHTML = html;
// Restore scroll position within chapter (after DOM paint)
if (scrollFrac && scrollFrac > 0) { if (scrollFrac && scrollFrac > 0) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -379,63 +537,92 @@
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
// Update header
const ch = chapters[index]; const ch = chapters[index];
document.getElementById('header-title').innerHTML = document.getElementById('header-title').innerHTML =
ch ? `<strong>${esc(ch.title)}</strong>` : ''; ch ? `<strong>${esc(ch.title)}</strong>` : '';
// Update nav
document.getElementById('btn-prev').disabled = index === 0; document.getElementById('btn-prev').disabled = index === 0;
document.getElementById('btn-next').disabled = index === chapters.length - 1; document.getElementById('btn-next').disabled = index === chapters.length - 1;
document.getElementById('chapter-nav-label').textContent = document.getElementById('chapter-nav-label').textContent =
`${index + 1} / ${chapters.length}`; `${index + 1} / ${chapters.length}`;
updateFooter(); updateFooter();
if (saveProgress) scheduleSave(); if (saveProgress) scheduleSave();
} }
function navigate(delta) { function navigate(delta) {
loadChapter(currentIndex + delta, true, 0); if (FORMAT === 'pdf') {
loadPdfPage(currentIndex + delta, true);
} else if (FORMAT === 'cbr' || FORMAT === 'cbz') {
loadCbrPage(currentIndex + delta, true);
} else {
loadChapter(currentIndex + delta, true, 0);
}
} }
// ── Scroll tracking ──────────────────────────────────────────── // ── Scroll tracking (EPUB only) ────────────────────────────────
window.addEventListener('scroll', () => { window.addEventListener('scroll', () => {
updateFooter(); updateFooter();
clearTimeout(scrollTimer); if (!IS_PAGED) {
scrollTimer = setTimeout(scheduleSave, 300); clearTimeout(scrollTimer);
scrollTimer = setTimeout(scheduleSave, 300);
}
}, { passive: true }); }, { passive: true });
// ── Keyboard navigation ──────────────────────────────────────── // ── Keyboard navigation ────────────────────────────────────────
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); navigate(1); } if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); navigate(1); }
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); navigate(-1); } if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); navigate(-1); }
if (e.key === 'Escape') closeSettings(); if (e.key === 'Escape') { closeSettings(); closeBookmarkModal(); }
}); });
// ── Init ─────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────
async function init() { async function init() {
loadWidth(); loadWidth();
loadTextColour();
const [r1, r2] = await Promise.all([ const progResp = await fetch(`/library/progress/${encodeURIComponent(filename)}`);
fetch(`/library/chapters/${encodeURIComponent(filename)}`), const prog = await progResp.json();
fetch(`/library/progress/${encodeURIComponent(filename)}`),
]);
chapters = await r1.json();
const prog = await r2.json();
// Bookmark navigation takes priority over saved progress
const urlParams = new URLSearchParams(window.location.search);
const bmCh = urlParams.get('bm_ch');
const bmScr = urlParams.get('bm_scroll');
let startIndex = 0; let startIndex = 0;
let startScroll = 0; let startScroll = 0;
if (prog.cfi) { if (bmCh !== null) {
startIndex = Math.max(0, parseInt(bmCh, 10) || 0);
startScroll = parseFloat(bmScr) || 0;
} else if (prog.cfi) {
const parts = prog.cfi.split(':'); const parts = prog.cfi.split(':');
const idx = parseInt(parts[0], 10); const idx = parseInt(parts[0], 10);
if (!isNaN(idx) && idx >= 0 && idx < chapters.length) { if (!isNaN(idx) && idx >= 0) {
startIndex = idx; startIndex = idx;
startScroll = parseFloat(parts[1]) || 0; startScroll = parseFloat(parts[1]) || 0;
} }
} }
await loadChapter(startIndex, false, startScroll); if (FORMAT === 'pdf') {
const infoResp = await fetch(`/api/pdf/info/${encodeURIComponent(filename)}`);
const info = await infoResp.json();
const pageCount = info.page_count || 1;
chapters = Array.from({ length: pageCount }, (_, i) => ({ index: i, title: `Page ${i + 1}` }));
if (startIndex >= chapters.length) startIndex = 0;
await loadPdfPage(startIndex, false);
} else if (FORMAT === 'cbr' || FORMAT === 'cbz') {
const infoResp = await fetch(`/api/cbr/info/${encodeURIComponent(filename)}`);
const info = await infoResp.json();
const pageCount = info.page_count || 1;
chapters = Array.from({ length: pageCount }, (_, i) => ({ index: i, title: `Page ${i + 1}` }));
if (startIndex >= chapters.length) startIndex = 0;
await loadCbrPage(startIndex, false);
} else {
const chapResp = await fetch(`/library/chapters/${encodeURIComponent(filename)}`);
chapters = await chapResp.json();
if (startIndex >= chapters.length) startIndex = 0;
await loadChapter(startIndex, false, startScroll);
}
document.getElementById('loading').style.display = 'none'; document.getElementById('loading').style.display = 'none';
} }
@ -445,10 +632,45 @@
window.location.href = `/library/book/${encodeURIComponent(filename)}`; window.location.href = `/library/book/${encodeURIComponent(filename)}`;
} }
function esc(s) { // ── Bookmarks ──────────────────────────────────────────────────
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); function openBookmarkModal() {
const chTitle = IS_PAGED
? `Page ${currentIndex + 1}`
: (chapters[currentIndex]?.title || `Chapter ${currentIndex + 1}`);
document.getElementById('bm-chapter').textContent = chTitle;
document.getElementById('bm-note').value = '';
document.getElementById('bm-overlay').classList.add('open');
setTimeout(() => document.getElementById('bm-note').focus(), 50);
} }
function closeBookmarkModal() {
document.getElementById('bm-overlay').classList.remove('open');
}
async function saveBookmark() {
const { scrollFrac } = calcProgress();
const chTitle = IS_PAGED
? `Page ${currentIndex + 1}`
: (chapters[currentIndex]?.title || `Chapter ${currentIndex + 1}`);
const note = document.getElementById('bm-note').value.trim();
closeBookmarkModal();
await fetch(`/library/bookmarks/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chapter_index: currentIndex,
scroll_frac: scrollFrac,
chapter_title: chTitle,
note,
}),
});
}
document.getElementById('bm-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeBookmarkModal();
});
init(); init();
</script> </script>
</body> </body>

View File

@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Search</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<style>
body { display: flex; min-height: 100vh; background: var(--bg); color: var(--text); font-family: var(--serif); }
.main { margin-left: var(--sidebar); flex: 1; padding: 2rem 2.5rem; max-width: 860px; }
@media (max-width: 768px) { .main { margin-left: 0; padding: 1rem; } }
.page-title { font-size: 1.4rem; font-weight: 700; margin-bottom: 1.5rem; }
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 2rem; }
.search-input {
flex: 1; padding: 0.55rem 0.8rem; border-radius: var(--radius);
border: 1px solid var(--border); background: var(--surface); color: var(--text);
font-size: 1rem; font-family: var(--serif);
}
.search-input:focus { outline: none; border-color: var(--accent); }
.search-btn {
padding: 0.55rem 1.1rem; border-radius: var(--radius); border: none;
background: var(--accent); color: #000; font-weight: 600; cursor: pointer;
font-size: 0.9rem;
}
.search-btn:hover { background: var(--accent2); }
.search-status { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem; font-family: var(--mono); }
.result-list { display: flex; flex-direction: column; gap: 1rem; }
.result-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1rem 1.2rem;
}
.result-book { font-size: 1rem; font-weight: 700; margin-bottom: 0.15rem; }
.result-book a { color: var(--text); text-decoration: none; }
.result-book a:hover { color: var(--accent); }
.result-author { font-size: 0.82rem; color: var(--text-dim); margin-bottom: 0.5rem; }
.result-chapter {
font-size: 0.8rem; color: var(--accent); font-family: var(--mono);
margin-bottom: 0.5rem;
}
.result-snippet {
font-size: 0.88rem; color: var(--text); line-height: 1.55;
border-left: 2px solid var(--border); padding-left: 0.75rem;
margin-bottom: 0.75rem;
}
.result-snippet mark {
background: rgba(255,162,14,0.25); color: var(--accent2);
border-radius: 2px; padding: 0 1px;
}
.result-actions { display: flex; gap: 0.5rem; }
.result-link {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.3rem 0.7rem; border-radius: var(--radius); font-size: 0.8rem;
text-decoration: none; border: 1px solid var(--border);
color: var(--text-dim); background: var(--surface2);
}
.result-link:hover { border-color: var(--accent); color: var(--accent); }
.result-link.primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
.result-link.primary:hover { background: var(--accent2); border-color: var(--accent2); }
.empty { color: var(--text-dim); text-align: center; padding: 3rem 0; font-size: 0.95rem; }
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<div class="page-title">Search</div>
<div class="search-bar">
<input class="search-input" id="search-input" type="search"
placeholder="Search inside books…" autocomplete="off"/>
<button class="search-btn" onclick="doSearch()">Search</button>
</div>
<div class="search-status" id="search-status"></div>
<div class="result-list" id="result-list"></div>
</main>
<script>
const input = document.getElementById('search-input');
const statusEl = document.getElementById('search-status');
const listEl = document.getElementById('result-list');
input.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
// Auto-run if ?q= param provided
const urlQ = new URLSearchParams(location.search).get('q');
if (urlQ) { input.value = urlQ; doSearch(); }
async function doSearch() {
const q = input.value.trim();
if (!q) { listEl.innerHTML = ''; statusEl.textContent = ''; return; }
history.replaceState(null, '', '/search?q=' + encodeURIComponent(q));
statusEl.textContent = 'Searching…';
listEl.innerHTML = '';
try {
const resp = await fetch('/api/search?q=' + encodeURIComponent(q));
if (!resp.ok) throw new Error('Search failed');
const results = await resp.json();
render(results, q);
} catch (e) {
statusEl.textContent = 'Search failed.';
}
}
function render(results, q) {
if (!results.length) {
statusEl.textContent = 'No results.';
listEl.innerHTML = '<div class="empty">No matches found for this query.</div>';
return;
}
statusEl.textContent = results.length + ' result' + (results.length === 1 ? '' : 's');
listEl.innerHTML = results.map(r => {
const enc = encodeURIComponent(r.filename);
const readUrl = '/library/read/' + enc + '?bm_ch=' + r.chapter_index + '&bm_scroll=0';
return `
<div class="result-card">
<div class="result-book"><a href="/library/book/${enc}">${esc(r.title || r.filename)}</a></div>
${r.author ? `<div class="result-author">${esc(r.author)}</div>` : ''}
<div class="result-chapter">Chapter ${r.chapter_index + 1}${r.chapter_title ? ' — ' + esc(r.chapter_title) : ''}</div>
<div class="result-snippet">${r.snippet}</div>
<div class="result-actions">
<a class="result-link primary" href="${readUrl}">Read here</a>
<a class="result-link" href="/library/book/${enc}">Book detail</a>
</div>
</div>`;
}).join('');
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<script src="/static/books.js"></script>
</body>
</html>

View File

@ -4,18 +4,15 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Settings</title> <title>Novela — Settings</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/> <link rel="stylesheet" href="/static/sidebar.css"/>
<style> <style>
:root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a; --accent2: #e8a063;
--text: #e8e2d9; --text-dim: #8a8278; --text-faint: #4a453e;
--success: #6baa6b; --warning: #c8a03a; --error: #c85a3a;
--radius: 6px; --sidebar: 220px;
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); } html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
@ -257,6 +254,7 @@
</div> </div>
</div> </div>
<script src="/static/books.js"></script>
<script> <script>
// ── Break patterns ───────────────────────────────────────────────────────── // ── Break patterns ─────────────────────────────────────────────────────────
let bpPatterns = []; let bpPatterns = [];
@ -383,9 +381,6 @@
fb.textContent = '✗ Not recognized as a break. (CSS classes are not tested here — they apply to HTML attributes.)'; fb.textContent = '✗ Not recognized as a break. (CSS classes are not tested here — they apply to HTML attributes.)';
} }
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Enter key in add inputs // Enter key in add inputs
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@ -4,19 +4,16 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Statistics</title> <title>Novela — Statistics</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/> <link rel="stylesheet" href="/static/sidebar.css"/>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style> <style>
:root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a; --accent2: #e8a063;
--text: #e8e2d9; --text-dim: #8a8278; --text-faint: #4a453e;
--success: #6baa6b; --warning: #c8a03a; --error: #c85a3a;
--radius: 6px; --sidebar: 220px;
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); } html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
@ -164,18 +161,16 @@
</div> </div>
</main> </main>
<script src="/static/books.js"></script>
<script> <script>
Chart.defaults.color = '#8a8278'; Chart.defaults.color = '#8a8278';
Chart.defaults.borderColor = '#2e2a24'; Chart.defaults.borderColor = '#2e2a24';
Chart.defaults.font.family = "'DM Mono', monospace"; Chart.defaults.font.family = "'DM Mono', monospace";
Chart.defaults.font.size = 11; Chart.defaults.font.size = 11;
const ACCENT = '#c8783a'; const ACCENT = '#ffa20e';
const ACCENT_A = 'rgba(200,120,58,0.15)'; const ACCENT_A = 'rgba(255,162,14,0.15)';
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function fmtDate(iso) { function fmtDate(iso) {
const d = new Date(iso); const d = new Date(iso);

View File

@ -167,3 +167,87 @@ def element_to_xhtml(el, break_img_path: str = "../Images/break.png", empty_p_is
for c in el.children: for c in el.children:
parts.append(element_to_xhtml(c, break_img_path, empty_p_is_spacer)) parts.append(element_to_xhtml(c, break_img_path, empty_p_is_spacer))
return "".join(parts) return "".join(parts)
def normalize_wysiwyg_html(raw_html: str, break_img_path: str = "../Images/break.png") -> str:
"""Normaliseer HTML uit de WYSIWYG-editor naar EPUB-compatibele XHTML.
Vervangt scene-breaks door de break-afbeelding, behoudt <strong>, <em>, <u>,
<blockquote>, <blockquote class="author-note">, wikkelt losse tekst in <p>,
verwijdert lege paragrafen.
"""
from bs4 import BeautifulSoup, NavigableString, Tag
soup = BeautifulSoup(raw_html or "", "html.parser")
body = soup.find("body") or soup
output_parts: list[str] = []
def process_inline(el) -> str:
if isinstance(el, NavigableString):
text = str(el)
return he(text) if text else ""
if el.name in ("strong", "b"):
inner = "".join(process_inline(c) for c in el.children)
return f"<strong>{inner}</strong>"
if el.name in ("em", "i"):
inner = "".join(process_inline(c) for c in el.children)
return f"<em>{inner}</em>"
if el.name == "u":
inner = "".join(process_inline(c) for c in el.children)
return f"<u>{inner}</u>"
if el.name == "br":
return "<br />"
return "".join(process_inline(c) for c in el.children)
def process_block(el) -> str | None:
if isinstance(el, NavigableString):
text = str(el).strip()
return f"<p>{he(text)}</p>" if text else None
if not isinstance(el, Tag):
return None
if is_break_element(el):
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
if el.name == "img":
src = el.get("src", "")
alt = he(el.get("alt", ""))
if "break" in src.lower():
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
return f'<img src="{he(src)}" alt="{alt}"/>' if src else None
if el.name in ("p", "div"):
if is_break_element(el):
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
inner = "".join(process_inline(c) for c in el.children).strip()
return f"<p>{inner}</p>" if inner else None
if el.name in ("h1", "h2", "h3", "h4"):
inner = "".join(process_inline(c) for c in el.children).strip()
return f"<{el.name}>{inner}</{el.name}>" if inner else None
if el.name == "blockquote":
classes = el.get("class", [])
css_class = " ".join(classes) if classes else ""
tag_open = f'<blockquote class="{css_class}">' if css_class else "<blockquote>"
parts = []
for child in el.children:
if isinstance(child, NavigableString):
text = str(child).strip()
if text:
parts.append(f"<p>{he(text)}</p>")
elif isinstance(child, Tag) and child.name in ("p", "div"):
inner = "".join(process_inline(c) for c in child.children).strip()
if inner:
parts.append(f"<p>{inner}</p>")
else:
r = process_block(child)
if r:
parts.append(r)
return f"{tag_open}{''.join(parts)}</blockquote>" if parts else None
if el.name == "hr":
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
parts = [r for c in el.children if (r := process_block(c))]
return "".join(parts) if parts else None
for child in list(body.children if hasattr(body, "children") else []):
result = process_block(child)
if result:
output_parts.append(result)
return "\n".join(output_parts)

View File

@ -16,29 +16,74 @@ It is the primary technical reference for the current implementation.
2. `close_pool()` 2. `close_pool()`
- Source-of-truth rule: files on disk are authoritative, the database is an index/cache. - Source-of-truth rule: files on disk are authoritative, the database is an index/cache.
## File Storage Paths
All files are stored under `library/` (relative to the app working directory, mapped via Docker volume).
`LIBRARY_DIR = Path("library")`, `LIBRARY_ROOT = LIBRARY_DIR.resolve()`.
### Path structure per format
| Format | Path pattern |
|--------|-------------|
| EPUB (no series) | `library/epub/{publisher}/{author}/Stories/{title}.epub` |
| EPUB (series) | `library/epub/{publisher}/{author}/Series/{series}/{idx:03d} - {title}.epub` |
| PDF | `library/pdf/{publisher}/{author}/{title}.pdf` |
| CBR (no series) | `library/comics/{publisher}/{author}/{title}.cbr` |
| CBR (series) | `library/comics/{publisher}/{author}/Series/{series}/{idx:03d} - {title}.cbr` |
| CBZ (no series) | `library/comics/{publisher}/{author}/{title}.cbz` |
| CBZ (series) | `library/comics/{publisher}/{author}/Series/{series}/{idx:03d} - {title}.cbz` |
- Segments are sanitised: special chars stripped, max lengths applied (publisher/author 80, title 140, series 80).
- Series index is zero-padded to 3 digits (`001`, `002`, …), clamped to 1999.
- Duplicate filenames get a `(2)`, `(3)`, … suffix.
- After any file move, empty parent directories are pruned up to `LIBRARY_ROOT`.
### Path logic
- `common.make_rel_path(media_type, publisher, author, title, series, series_index, ext)` — used by import and grabber.
- `reader.py _make_rel_path(publisher, author, title, series, series_index, ext)` — used by metadata PATCH; same logic, uses actual file extension.
- Both functions produce identical paths for all formats.
### Metadata save behaviour per format
| Format | File written? | DB written? |
|--------|--------------|-------------|
| EPUB | Yes — OPF metadata updated in-place | Yes |
| PDF | No | Yes |
| CBR | No | Yes |
| CBZ | No (tags/metadata); rating written to ComicInfo.xml | Yes |
---
## Router Status ## Router Status
### `routers/library.py` ### `routers/library.py`
- `GET /library` - `GET /library` — library page
- `GET /api/library` - `GET /api/library` — book list JSON (fast-path by default)
- `POST /library/rescan` - `POST /library/rescan` — forced full disk rescan
- `POST /library/import` (EPUB/PDF/CBR/CBZ) - `POST /library/import` — upload EPUB/PDF/CBR/CBZ
- `DELETE /library/file/{filename}` - `DELETE /library/file/{filename}` — delete file + DB row + prune dirs
- `GET /library/cover/{filename}` - `GET /download/{filename}` — download file with `Content-Disposition: attachment`
- `GET /library/cover-cached/{filename}` - `GET /library/cover/{filename}` — serve cover (EPUB from file; PDF/CBR from cache)
- `POST /library/cover/{filename}` (EPUB) - `GET /library/cover-cached/{filename}` — serve cover from DB cache only
- `POST /library/want-to-read/{filename}` - `POST /library/cover/{filename}` — upload/replace cover (EPUB only)
- `POST /library/archive/{filename}` - `POST /library/want-to-read/{filename}` — toggle want-to-read flag
- `POST /library/new/mark-reviewed` (bulk `needs_review=false`) - `POST /library/archive/{filename}` — toggle archived flag
- `GET /home` - `POST /library/new/mark-reviewed` — bulk set `needs_review=false`
- `GET /api/home` - `POST /library/bulk-delete` — delete multiple files; accepts `{"filenames": [...]}`, removes files from disk and DB in one query per batch; returns `{ok, deleted, skipped}`
- `GET /stats` - `POST /library/rating/{filename}` — set/clear star rating `{"rating": 0-5}`
- `GET /api/stats` - `GET /home` — home page
- `GET /library/list` (compat) - `GET /api/home` — home data JSON
- `GET /stats` — statistics page
- `GET /api/stats` — statistics data JSON
- `GET /api/disk` — partition usage for the library directory: `{total, used, free, pct_used}`
- `POST /api/bulk-check-duplicates` — accepts `{"items": [{title, author, volume}, ...]}`, returns `{"duplicates": [bool, ...]}` — when `volume` is a number, requires title+author+series_index to all match; when volume is absent, matches on title+author only
- `GET /library/list` — compat alias
`GET /api/library` runs in fast-path mode by default (DB-only, no full disk rescan). `GET /api/library` runs in fast-path mode by default (DB-only, no full disk rescan).
For a forced sync: `GET /api/library?rescan=true` or `POST /library/rescan`. For a forced sync: `GET /api/library?rescan=true` or `POST /library/rescan`.
`include_file_info=true` is optional for file size/mtime enrichment. `include_file_info=true` is optional for file size/mtime enrichment.
ETag caching: response includes `ETag: "{count}-{max_updated_at_unix}"` and `Cache-Control: no-cache`. Client sends `If-None-Match`; server returns `304 Not Modified` when nothing changed.
`/api/home` returns: `/api/home` returns:
- `continue_reading` - `continue_reading`
@ -61,43 +106,141 @@ Home read sections are ordered oldest-first:
- `novels_read`: `ORDER BY MAX(read_at) ASC` - `novels_read`: `ORDER BY MAX(read_at) ASC`
### `routers/reader.py` ### `routers/reader.py`
- EPUB serving/chapters/images - `GET /library/db-images/{path:path}` — serve image from content-addressed imagestore (`library/images/`); security: path must be under `IMAGES_DIR`
- Reader page + book detail - `POST /api/library/convert-to-db/{filename:path}` — convert on-disk EPUB to a DB-stored book; extracts chapters via `_epub_body_inner` (stores images in imagestore, rewrites src to `/library/db-images/…`), migrates all child tables (INSERT new library row → UPDATE children → DELETE old row), deletes EPUB file; returns `{ok, new_filename}`
- Metadata patch (`PATCH /library/book/{filename}`) - `GET /api/library/export-epub/{filename:path}` — build and stream an EPUB from a DB-stored book; `_rewrite_db_images_for_epub` rewrites `/library/db-images/…` back to `OEBPS/Images/…` paths (dedup by sha256); returns as `Content-Disposition: attachment`
- Progress read/write/delete - `GET /library/epub/{filename}` — serve EPUB inline (no attachment header)
- Mark-as-read - `GET /library/chapters/{filename}` — EPUB spine as JSON; for `storage_type='db'` books returns chapters from `book_chapters`
- PDF render endpoint - `GET /library/chapter/{index}/{filename}` — single chapter as HTML fragment; for `storage_type='db'` books reads from `book_chapters`
- CBR/CBZ page endpoint - `GET /library/chapter-img/{path}?filename=…` — image extracted from EPUB ZIP; `path` is the full internal ZIP path (e.g. `OEBPS/Images/cover.jpg` or `EPUB/images/cover.jpg`); case-insensitive fallback for mismatched folder names
- Genres endpoint - `GET /library/pdf/{filename}?page=N&dpi=150` — render PDF page as PNG
- `GET /api/pdf/info/{filename}``{"page_count": N}`
- `GET /library/cbr/{filename}/{page}` — CBR/CBZ page as image
- `GET /library/progress/{filename}` — read progress
- `POST /library/progress/{filename}` — save progress `{"cfi": "…", "progress": N}`
- `DELETE /library/progress/{filename}` — clear progress
- `POST /library/mark-read/{filename}` — mark as read (with optional date)
- `GET /library/book/{filename}` — book detail page
- `GET /api/genres` — all tags from `book_tags` (optional `?type=genre|subgenre|tag`)
- `PATCH /library/book/{filename}` — update metadata + tags; moves file if path fields change; DB-only for non-EPUB; for `storage_type='db'` books: recomputes synthetic `db/…` filename, FK-safe rename (INSERT→UPDATE children→DELETE old), updates `book_chapters` + `bookmarks` as well
- `POST /library/rating/{filename}` — set/clear 15 star rating; writes to EPUB OPF / CBZ ComicInfo.xml; DB-only for CBR/PDF
- `GET /library/read/{filename}` — reader page (EPUB or PDF); supports `?bm_ch=N&bm_scroll=F` to jump to bookmark position
- `GET /library/bookmarks/{filename}` — list bookmarks for a book
- `POST /library/bookmarks/{filename}` — add bookmark `{chapter_index, scroll_frac, chapter_title, note}`
- `PATCH /library/bookmarks/{id}` — update bookmark note
- `DELETE /library/bookmarks/{id}` — delete bookmark
- `GET /api/bookmarks` — all bookmarks across all books (includes `book_title`, `book_author`)
### `routers/bulk_import.py`
- `GET /bulk-import` — Bulk Import page
- `POST /library/bulk-import` — import files with pre-parsed metadata; accepts multipart `files[]`, `rows` (JSON array of per-file metadata), `shared` (JSON with author/publisher/status/genres/tags applied to all files)
Filename parsing is done client-side in `bulk_import.html`. The page uses a free-text `%placeholder%` pattern (e.g. `%series% - %volume% - %title% - %year%`). Available placeholders: `%series%` `%volume%` `%title%` `%year%` `%month%` `%day%` `%author%` `%publisher%` `%ignore%`. Colored chips can be clicked (insert at cursor) or dragged onto the input. Pattern is converted to a regex at parse time. Shared metadata fields override filename-parsed values. Files are uploaded in batches of 5 with a progress bar.
### `routers/editor.py` ### `routers/editor.py`
- Editor page - `GET /library/editor/{filename}` — chapter editor page; supports both EPUB files and DB-stored books (`db/…` filenames); passes `is_db` flag to template; DB branch queries `library` table directly (no file check)
- Chapter get/save - `GET /api/edit/chapter/{index}/{filename}` — get chapter content; DB branch reads from `book_chapters` and returns `{index, href, title, content}`
- Chapter add - `POST /api/edit/chapter/{index}/{filename}` — save chapter; DB branch accepts `{content, title}`, calls `upsert_chapter` (updates `content_tsv` too)
- Chapter delete - `POST /api/edit/chapter/add/{filename}` — add new chapter after `after_index`; DB branch shifts `chapter_index` up via `UPDATE … SET chapter_index = chapter_index + 1 WHERE chapter_index >= insert_idx` then inserts
- `DELETE /api/edit/chapter/{index}/{filename}` — delete chapter; DB branch deletes and re-indexes via `UPDATE … SET chapter_index = chapter_index - 1 WHERE chapter_index > index`
### `routers/grabber.py` ### `routers/grabber.py`
- Grabber page + convert/debug flows - `GET /grabber` — grabber page
- SSE events - `GET /convert` — convert page
- Credential management for scraper sites - `GET /credentials-manager` — credentials manager UI
- Credentials manager UI (`/credentials-manager`) - `GET /debug` — debug page
- `POST /debug/run` — run debug scrape
- `GET /credentials` — list stored credentials
- `POST /credentials` — save credential
- `DELETE /credentials/{site}` — delete credential
- `POST /preload` — preload book info from URL
- `POST /convert` — run scrape; body may include `storage_mode: "db"` (default) or `"epub"` to control output format
- `GET /events/{job_id}` — SSE stream for job progress; `done` event includes `storage_type` (`'db'` or `'file'`)
Scrape/convert flow (DB storage — default):
1. Fetch book info + chapters via scraper
2. Per chapter: download images → write to `library/images/{sha2}/{sha256}{ext}` (content-addressed) → rewrite `img[src]` to `/library/db-images/...` → build `content_html` via `element_to_xhtml`
3. One DB transaction: `ensure_unique_db_filename``upsert_book` (storage_type='db') → `upsert_chapter` for each chapter → `upsert_cover_cache` if cover provided
4. Synthetic filename: `db/{publisher}/{author}/{title}` (or `db/{pub}/{auth}/Series/{series}/{idx} - {title}` for series)
Scrape/convert flow (EPUB file — `storage_mode: "epub"`):
12. Same as DB flow (images downloaded, HTML built)
3. Chapters converted to XHTML via `make_chapter_xhtml`; EPUB file built via `make_epub` and written to `library/epub/…`
4. `upsert_book` called with `storage_type='file'`
### `routers/search.py`
- `GET /search` — full-text search page (`search.html`); Enter-to-search, `?q=` param auto-runs on load
- `GET /api/search?q=…` — FTS over `book_chapters.content_tsv`; uses `plainto_tsquery('simple', q)` with `ts_rank` ordering and `ts_headline` for highlighted snippets; also matches chapters whose `title` contains the query (case-insensitive `ILIKE` fallback); LIMIT 30; excludes archived books; results include `filename`, `title`, `author`, `chapter_index`, `chapter_title`, `snippet`, `rank`
### `routers/settings.py`
- `GET /settings` — settings page
- `GET /api/break-patterns` — list chapter-break patterns
- `POST /api/break-patterns` — add break pattern (type: `regex` or `css_class`)
- `PATCH /api/break-patterns/{id}` — update pattern (enable/disable or change value)
- `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/following.py`
- `GET /following` — Following page (author URL management)
- `GET /api/following` — all distinct library authors with URL (if set), book count, and last-added date
- `POST /api/following/{author_name}` — set or clear URL for an author (empty `url` removes the record)
`GET /api/following` returns one entry per non-archived author:
```json
{ "name": "Author Name", "book_count": 5, "last_added": "2026-03-27T…", "url": "https://…" }
```
URL is stored in the `authors` table (`name` unique, `url`, `created_at`, `updated_at`).
### `routers/backup.py` ### `routers/backup.py`
- `GET /backup` - `GET /backup` — backup page
- `GET/POST/DELETE /api/backup/credentials` - `GET /api/backup/credentials` — Dropbox settings (includes `app_key_configured` flag)
- `GET /api/backup/health` - `POST /api/backup/credentials` — save Dropbox settings
- `GET /api/backup/status` - `DELETE /api/backup/credentials` — remove all Dropbox credentials
- `GET /api/backup/history` - `POST /api/backup/oauth/prepare` — save app key + secret, return Dropbox auth URL
- `POST /api/backup/run` - `POST /api/backup/oauth/exchange` — exchange authorization code for refresh token
- `GET /api/backup/health` — Dropbox connectivity check (includes `schedule_enabled`, `schedule_interval_hours`)
- `GET /api/backup/status` — current backup status
- `GET /api/backup/history` — backup run history (last 20)
- `GET /api/backup/progress` — live progress of running backup `{running, done, total, phase}`
- `POST /api/backup/run` — trigger backup (background task)
- `GET /api/backup/snapshots` — list available snapshots `{ok, snapshots: [{name, created_at}]}`
- `GET /api/backup/snapshots/{snapshot_name}/files` — list files in a snapshot with local existence check `{ok, snapshot, files: [{path, size, sha256, exists_locally}]}`
- `POST /api/backup/restore` — restore files from a snapshot: `{snapshot_name, files: [rel_paths]}`; downloads from Dropbox, writes to disk, re-indexes via `scan_media` + `upsert_book`; returns `{ok, restored, total, results: [{path, ok, error?}]}`
---
## Backup & Security ## Backup & Security
- Dropbox token is stored encrypted-at-rest in `credentials` (`site='dropbox'`). - Dropbox token (refresh token or legacy access token) stored encrypted in `credentials` (`site='dropbox'`).
- Dropbox backup root is stored encrypted in `credentials` (`site='dropbox_backup_root'`). - Dropbox app key stored encrypted in `credentials` (`site='dropbox_app_key'`).
- Retention (`snapshots to keep`) is stored encrypted in `credentials` (`site='dropbox_backup_retention'`). - Dropbox app secret stored encrypted in `credentials` (`site='dropbox_app_secret'`).
- Backup schedule (`enabled` + `interval_hours`) is stored encrypted in `credentials` (`site='dropbox_backup_schedule'`). - Dropbox backup root stored encrypted in `credentials` (`site='dropbox_backup_root'`).
- Retention (`snapshots to keep`) stored encrypted in `credentials` (`site='dropbox_backup_retention'`).
- Backup schedule (`enabled` + `interval_hours`) stored encrypted in `credentials` (`site='dropbox_backup_schedule'`).
- Encryption uses `NOVELA_MASTER_KEY` (Fernet). - Encryption uses `NOVELA_MASTER_KEY` (Fernet).
Implementation details: ### Dropbox authentication
- Preferred: OAuth2 refresh token (does not expire). Set up via the two-step flow on `/backup`:
1. Enter App Key + App Secret → click **Generate Auth URL**
2. Approve in browser → paste the code → click **Save & Activate**
- `_dbx()` uses `oauth2_refresh_token` + `app_key` + `app_secret` for automatic token renewal.
- Fallback: legacy short-lived access token (backwards compatible; works without app key/secret).
### Implementation details
- Versioned backups with deduplication: - Versioned backups with deduplication:
- file objects in Dropbox: `library_objects/{sha256_prefix}/{sha256}` - file objects in Dropbox: `library_objects/{sha256_prefix}/{sha256}`
- snapshots in Dropbox: `library_snapshots/snapshot-YYYYMMDD-HHMMSS.json` - snapshots in Dropbox: `library_snapshots/snapshot-YYYYMMDD-HHMMSS.json`
@ -107,10 +250,13 @@ Implementation details:
- Local manifest cache (`config/backup_manifest.json`) speeds up change detection. - Local manifest cache (`config/backup_manifest.json`) speeds up change detection.
- Database backup is done via `pg_dump` to Dropbox `postgres/`. - Database backup is done via `pg_dump` to Dropbox `postgres/`.
- `POST /api/backup/run` always starts a background task and returns immediately. - `POST /api/backup/run` always starts a background task and returns immediately.
- `GET /api/backup/progress` returns in-memory progress updated per file; phases: `starting``scanning``uploading``snapshot``pg_dump`.
- Scheduler runs in the background (`start_backup_scheduler`) and triggers on interval when enabled. - Scheduler runs in the background (`start_backup_scheduler`) and triggers on interval when enabled.
- Concurrency guard: only one backup can run at a time. - Concurrency guard: only one backup can run at a time.
- After container restart/crash, stale `running` logs are auto-marked as interrupted/error. - After container restart/crash, stale `running` logs are auto-marked as interrupted/error.
---
## Environment ## Environment
`stack/novela.env` should include at least: `stack/novela.env` should include at least:
- `POSTGRES_DB` - `POSTGRES_DB`
@ -121,47 +267,223 @@ Implementation details:
Dropbox settings are managed via the web UI on `/backup`. Dropbox settings are managed via the web UI on `/backup`.
---
## Branding
Static assets in `static/`:
| File | Size | Purpose |
|------|------|---------|
| `logo.png` | 546×575, transparent | Sidebar wordmark (displayed at 26px height) |
| `favicon.ico` | 16×16 | Browser tab (legacy) |
| `favicon-32.png` | 32×32 | Browser tab (modern) |
| `favicon-256.png` | 256×256 | Pinned tabs / high-DPI |
| `apple-touch-icon.png` | 180×180 | iOS/iPadOS home screen icon |
All 15 page templates include:
```html
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
```
Sidebar logo: `logo.png` (26px, flex-aligned) next to the "No**vela**" wordmark ("No" in `--text`, "vela" in `--accent`).
`apple-touch-icon.png` uses `#0f0e0c` background (= `--bg`) with the orange N logo centered at 60% of canvas size.
---
## Shared CSS (`static/theme.css`)
Single `:root { }` block defining all global CSS custom properties. Loaded first on every page (`<link rel="stylesheet" href="/static/theme.css"/>`). No template defines its own global colours — only page-specific layout vars stay inline.
| Variable | Value | Role |
|---|---|---|
| `--bg` | `#0f0e0c` | Page background |
| `--surface` | `#1a1815` | Card/panel background |
| `--surface2` | `#221f1b` | Nested surface |
| `--border` | `#2e2a24` | Borders |
| `--accent` | `#ffa20e` | Orange highlight (logo colour) |
| `--accent2` | `#ffb840` | Lighter orange |
| `--text` | `#e8e2d9` | Body text |
| `--text-dim` | `#8a8278` | Muted text |
| `--text-faint` | `#4a453e` | Very muted text |
| `--success` | `#6baa6b` | Success state |
| `--warning` | `#c8a03a` | Warning state |
| `--error` | `#c85a3a` | Error state |
| `--radius` | `6px` | Border radius |
| `--sidebar` | `220px` | Sidebar width |
| `--mono` | `'DM Mono', monospace` | Monospace font stack |
| `--serif` | `'Libre Baskerville', Georgia, serif` | Serif font stack |
Page-specific overrides: `reader.html` (`--header-h`, `--footer-h`, `--content-w`); `backup.html` (`--ok`, `--warn`, `--err`); `editor.css` (`--danger`, `--header-h`, `--panel-w`).
## Shared JavaScript (`static/books.js`)
Loaded before any page-specific script on every page that needs book data or UI helpers.
| Function | Purpose |
|---|---|
| `esc(s)` | HTML-escape a string for safe insertion into markup |
| `strHash(s)` | Deterministic integer hash of a string (for colour selection) |
| `COVER_PALETTES` | Array of 8 `[bg, fg]` colour pairs for placeholder covers |
| `wrapText(ctx, text, x, y, maxW, lineH)` | Canvas word-wrap helper |
| `truncate(s, n)` | Truncate string with ellipsis |
| `makePlaceholderCover(canvas, title, author)` | Draw a generated book cover on a `<canvas>` |
| `_filenameBase(filename)` | Strip path and extension from a filename |
| `bookTitle(b)` | Return display title (falls back to filename parsing) |
| `bookAuthor(b)` | Return display author (falls back to filename parsing) |
| `tagValuesByType(b, type)` | Return tag strings of a given type from `b.tags` |
| `bookGenres(b)` | Tags of type `genre`; falls back to `subject` |
| `bookSubgenres(b)` | Tags of type `subgenre` |
| `bookPlainTags(b)` | Tags of type `tag` |
| `filterBooks(books, query)` | Filter book list by query across title, author, publisher, genre, sub-genre, tag |
| `setupSearchInput(inputId, clearId, onSearch)` | Wire input: show/hide clear button on input; call `onSearch(query)` on Enter |
## Shared JavaScript (`static/conversion.js`)
Loaded by `index.html` (Convert page) and `grabber.html` (Grabber page). Requires `books.js` for `esc()`.
| Function | Purpose |
|---|---|
| `addLog(msg, cls)` | Append a log line to `#log-lines` |
| `connectConversionStream(job_id)` | Open SSE stream `/events/{job_id}` and handle all conversion events: `status`, `meta`, `chapters`, `progress`, `warning`, `error`, `done` |
## UI Notes ## UI Notes
- Library import accepts EPUB/PDF/CBR/CBZ. - Library import accepts EPUB/PDF/CBR/CBZ.
- Home supports the same import formats. - Home supports the same import formats.
- Home includes search. - Home includes search.
- Home header/dropzone alignment matches Library (search top-right, dropzone below). - Home header/dropzone alignment matches Library (search top-right, dropzone below).
- `New` view supports `Grid` and `List` mode. - `New` view supports `Grid` and `List` mode.
- Bulk selection + `Remove from New` works only in `List` mode. - Bulk selection + `Remove from New` works only in `List` mode.
- `List` mode has a column visibility filter with columns: - `List` mode has a column visibility filter: Publisher, Author, Series, Volume, Title, Has cover, Updated, Genres, Sub-genres, Tags, Status.
- Publisher - `List` mode supports multi-select with `Shift+click` range selection on checkboxes.
- Author - `Grid` mode shows no selection checkboxes or bulk actions.
- Series - `All books` view supports `Grid` and `List` mode (same columns as `New`).
- Volume - View mode persisted in `localStorage` as `novela.all.viewMode`.
- Title - Column visibility persisted in `localStorage` as `novela.all.visibleColumns`.
- Has cover - `List` mode has a checkbox column, column visibility filter, and multi-select with `Shift+click` range selection.
- Updated - `List` mode has a `Delete selected` bulk action: confirms then calls `DELETE /library/file/{filename}` for each selected book.
- Genres - Publication status values: `Complete`, `Ongoing`, `Temporary Hold`, `Long-Term Hold` (blank = unknown). `Hiatus` was renamed to `Long-Term Hold` via startup migration `migrate_rename_hiatus()`.
- Sub-genres - Status badges (top-right of grid card cover): circular icon, dark fill `rgba(15,14,12,0.82)` + `box-shadow: 0 0 0 2px #0f0e0c` ring for visibility on any cover colour. Icon colour per status: Complete=green `#6baa6b`, Ongoing=blue `#4a90b8`, Temporary Hold=amber `#c8a03a`, Long-Term Hold=orange `#c8783a`. `statusBadgeHtml()` in `library.js` is the single source for badge HTML across all grid views.
- Tags - Want-to-read star (top-left of grid card cover): same dark fill + ring as status badges.
- Status - Status pills in Book Detail (`book.css`): `status-complete`, `status-ongoing`, `status-temporary-hold`, `status-long-term-hold` — same colour scheme as badges.
- `List` mode supports multi-select with `Shift+click` range selection on checkboxes. - Grabber status mapping (`grabber.py`): `Temporary-Hold` (gayauthors.org) → `Temporary Hold`; `Long-Term Hold` passes through unchanged.
- `Grid` mode shows no selection checkboxes or bulk actions. - Star ratings (15) shown under the cover in all grid views:
- Backup page supports: - Display-only in grid cards (no click, prevents accidental taps while scrolling).
- manual run and dry-run - Interactive in Book Detail (1.1rem, clickable; clicking the active star clears the rating).
- Dropbox root settings - Amber: filled `#c8a03a`, unfilled `rgba(200, 160, 58, 0.25)`.
- snapshot retention count - Reader settings (hamburger menu):
- scheduled backup (on/off + interval in hours) - Content width slider (30100 vw), persisted as `reader-content-width-pct`.
- status + history overview - Text colour: 5 warm-tone presets `#e8e2d9``#938d86`, persisted as `reader-text-colour`.
- Hamburger and back-link separated with `margin-left: 1rem` on `.header-back`.
- Reader supports EPUB and PDF:
- EPUB: chapter-text rendering; progress = `{chapterIndex}:{scrollFrac}`; progress % = `(chapterIndex + scrollFrac) / total * 100`.
- PDF: page-image rendering via `/library/pdf/{filename}?page=N`; page count from `/api/pdf/info/{filename}`; progress = `{pageIndex}:0`; keyboard/button navigation identical.
- `reader.html` branches on `FORMAT` variable injected by the server.
- `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.
- Convert page: after loading metadata, if a book with the same title+author already exists in the library, a warning banner is shown (with a link to the existing book); user can still proceed with conversion. Check is done server-side in `/preload` response (`already_exists`, `existing_books`).
- Duplicates view (`#duplicates`): groups non-archived books by `(title, author)` (case-insensitive); shows only groups with ≥ 2 copies; counter in sidebar shows total number of duplicate books. Detection is entirely client-side from the existing library data.
- Incomplete view (`#incomplete`): shows all non-archived books where `publication_status` is not `Complete` (Ongoing, Temporary Hold, Long-Term Hold, or blank); sidebar counter included.
- Following page (`/following`): dedicated page in its own sidebar section between Library and Tools; shows all library authors with their external URL; two tabs — Following (authors with URL set) and All Authors; inline URL editing with keyboard support (Enter = save, Escape = cancel); clicking Visit opens the external URL in a new tab. Author URLs are stored in the `authors` table. Sidebar counter shows number of followed authors.
- 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()`.
---
## Known Conventions ## Known Conventions
- Book deletion flow: delete file, prune empty directories, then `DELETE FROM library` (cascade removes child rows). - Book deletion flow: `unlink` file → `prune_empty_dirs(parent)``DELETE FROM library` (cascade removes child rows).
- Empty dir pruning: `prune_empty_dirs(start)` walks up from `start` to `LIBRARY_ROOT`, removing each dir if empty; stops at first non-empty dir.
- Cover strategy: - Cover strategy:
- EPUB: cover from file + cache - EPUB: `GET /library/cover/{filename}` checks `library_cover_cache` first; on miss, extracts from ZIP and warms the cache. Cover upload (`POST /library/cover/{filename}`) replaces the image inside the EPUB ZIP (OPF located via `META-INF/container.xml`, old cover found in manifest and removed) and updates the cache so subsequent requests return the new cover immediately.
- PDF/CBR: thumbnail via cover cache - PDF: first page rendered as thumbnail, cached
- CBR/CBZ: first page extracted, cached
- Rating storage:
- EPUB: `<meta name="novela:rating" content="N"/>` in OPF
- CBZ: `<NovelaRating>N</NovelaRating>` in `ComicInfo.xml` inside the ZIP
- CBR/PDF: DB only
- `upsert_book` uses `CASE WHEN EXCLUDED.rating > 0 THEN EXCLUDED.rating ELSE library.rating END` to restore rating from file without overwriting existing DB value.
- Tag types in `book_tags`: `genre`, `subgenre`, `tag`, `subject`. No direct `genres`/`subgenres` fields on book objects; always use helpers `bookGenres()`, `bookSubgenres()`, `bookPlainTags()`.
---
## Performance Notes ## Performance Notes
- Library load is optimized for large datasets: - Library load is optimized for large datasets (1000+ books):
- `list_library_json()` uses pre-aggregation for `reading_sessions`. - `list_library_json()` uses `json_agg` in the main query to inline tags per book — eliminates a separate `SELECT * FROM book_tags` query and Python merge loop.
- `has_cached_cover` is provided directly via SQL join instead of full cache fetch. - `has_cached_cover` is provided directly via SQL join instead of full cache fetch.
- `reading_sessions` is pre-aggregated in a subquery.
- ETag on `/api/library`: cheap `COUNT + MAX(updated_at)` query before full load; `304 Not Modified` on cache hit.
- Front-end rendering uses `IntersectionObserver` to defer both cover image loading and placeholder canvas drawing until cards enter the viewport — prevents hundreds of simultaneous HTTP requests and canvas operations on initial render.
- `renderBooksGrid`, `renderDuplicatesView`, `renderSeriesDetail` all use a single DOM pass: cover `<img>` and `<canvas>` are set up via `card.querySelector` immediately after `innerHTML` is set, eliminating a second full iteration with `document.getElementById` calls.
- Additional migration indexes: - Additional migration indexes:
- `idx_library_sort_coalesce` - `idx_library_sort_coalesce`
- `idx_library_needs_review` - `idx_library_needs_review`
- `idx_library_archived` - `idx_library_archived`
- `idx_reading_sessions_filename_readat` - `idx_reading_sessions_filename_readat`
- `idx_book_tags_filename_tag` - `idx_book_tags_filename_tag`
---
## DB-Stored Books
Books scraped via the grabber are stored entirely in PostgreSQL (`storage_type = 'db'`). No EPUB file is written.
### New tables
| Table | Key columns | Notes |
|---|---|---|
| `book_chapters` | `filename FK, chapter_index, title, content TEXT, content_tsv TSVECTOR` | Unique on `(filename, chapter_index)`; GIN index on `content_tsv` for FTS; `content_tsv` is `to_tsvector('simple', title || ' ' || stripped_html)` — title included for title-based FTS matches |
| `book_images` | `sha256 PK, ext, media_type, size_bytes` | Content-addressed; files live at `library/images/{sha256[:2]}/{sha256}{ext}` |
### `library.storage_type`
| Value | Meaning |
|---|---|
| `'file'` | Book lives on disk (EPUB/PDF/CBR/CBZ); default for all existing books |
| `'db'` | Book content lives in `book_chapters`; no file on disk |
### Synthetic filename for DB books
`db/{publisher}/{author}/{title}` — or for series: `db/{publisher}/{author}/Series/{series}/{idx:03d} - {title}`
Same sanitization rules as file-based paths. Uniqueness enforced via `ensure_unique_db_filename` (DB lookup, not filesystem).
### Chapter editor for DB books
`GET /library/editor/{filename}` supports DB-stored books. The Monaco editor shows `language: 'html'` for DB books (vs `'xml'` for EPUB). The header shows a title input instead of a read-only chapter name. Unsaved content and titles are preserved across chapter switches via `pendingContent` and `pendingTitles` maps. `editor.focus()` is called after every content load so the editor is immediately interactive.
### Imagestore
Images embedded in chapter HTML are stored content-addressed at `library/images/{sha256[:2]}/{sha256}{ext}`.
- Served via `GET /library/db-images/{path:path}`
- URLs embedded in `book_chapters.content` as absolute paths: `/library/db-images/...`
- `book_images` table registers each unique image (auto-deduplication via sha256)
### EPUB → DB conversion
`POST /api/library/convert-to-db/{filename}` converts an on-disk EPUB to `storage_type='db'`:
1. Parse EPUB spine → per item: extract body HTML via `_epub_body_inner`, store images in imagestore via `write_image_file`, rewrite `img[src]` to `/library/db-images/…`
2. Compute new synthetic `db/…` filename via `make_rel_path(media_type="db", …)` + `ensure_unique_db_filename`
3. DB transaction: INSERT new library row (storage_type='db') → UPDATE all child tables (book_tags, reading_progress, reading_sessions, bookmarks, library_cover_cache, book_chapters) → DELETE old library row
4. Delete EPUB file from disk + `prune_empty_dirs`
### DB → EPUB export
`GET /api/library/export-epub/{filename}` streams an EPUB built from DB content:
1. Query metadata, tags, chapters, cover from DB
2. Per chapter: `_rewrite_db_images_for_epub` strips `/library/db-images/` prefix, reads files from `IMAGES_DIR`, deduplicates by sha256, assigns `OEBPS/Images/{sha256}{ext}` paths, rewrites `img[src]` to `../Images/…`
3. Build EPUB via `make_epub()`; return as `Content-Disposition: attachment`
---
## Known Bugs Fixed
- `renderGenreView` and `renderSearchResults` in `library.js` referenced `b.genres` (non-existent). Fixed: use `bookGenres()`, `bookSubgenres()`, `bookPlainTags()`.
- `PillInput` in `book.js` did not handle comma as delimiter and did not flush on save. Fixed: comma keydown + `flush()` in `saveEdit()`.
- `PATCH /library/book` failed for PDFs: `_sync_epub_metadata` tried to open PDF as ZIP. Fixed: only called for `.epub`.
- `_make_rel_path` in `reader.py` lacked format prefix (`epub/`, `pdf/`, `comics/`). Fixed: aligned with `common.make_rel_path`.
- `common.make_rel_path` always generated `.cbr` extension for CBZ files (both map to `media_type="cbr"`). Fixed: accepts optional `ext` parameter; `library.py` import now passes actual suffix.
- `/download/{filename}` was referenced in `book.html` but no endpoint existed (404). Fixed: added `GET /download/{filename}` to `library.py`.
- PDF reader showed infinite loading: `reader.html` called EPUB-only `/library/chapters/`. Fixed: PDF path uses `/api/pdf/info/` + page-image rendering.
- Empty dir pruning only ran when file was moved. Fixed: `prune_empty_dirs(old_path.parent)` always runs after a successful metadata save.

View File

@ -1,8 +1,251 @@
# Develop Changelog # Develop Changelog
## 2026-04-03 (3)
- DB chapter editor: Monaco-based editor now supports DB-stored books
- `GET /library/editor/{filename}` handles `db/…` filenames; `is_db` flag passed to template
- `GET /api/edit/chapter/{index}/{filename}` and `POST …`: DB branches query/update `book_chapters` directly; save calls `upsert_chapter` (updates `content_tsv` too)
- `POST /api/edit/chapter/add/{filename}` and `DELETE …`: DB branches insert/delete with `chapter_index` shift via `UPDATE … SET chapter_index = chapter_index ± 1`
- Title editing: header chapter-name replaced with a text input for DB books; `pendingTitles` map preserves unsaved titles across chapter switches (parallel to `pendingContent`); title-only dirty chapters correctly saved in Save All
- `insertBreak`: scene-break image path is `/static/break.png` for DB books (vs `../Images/break.png` for EPUB)
- Fix: `editor.focus()` called after content load so Monaco receives keyboard focus immediately
- Fix: `header-chapter` "Loading…" text suppressed for DB books where that element is hidden
- `book.html`: "Edit chapters" button shown for `storage_type = 'db'` books
- Search: chapter titles now included in FTS
- `upsert_chapter` prepends title to the plain-text input for `to_tsvector`: `title + " " + stripped_html`
- `GET /api/search`: added `OR LOWER(bc.title) LIKE LOWER('%…%')` fallback for chapters whose title matches but content doesn't
- Startup migration `migrate_rebuild_chapter_tsv_with_title()` rebuilds existing `content_tsv` values to include titles
- Grabber: added DB/EPUB storage toggle on the Convert page
- UI toggle above Convert button ("Save as: DB | EPUB file"); `storageMode` JS variable sent in POST body
- `POST /convert`: reads `storage_mode` from body; stored in job as `'db'` or `'epub'`
- `_run_scrape`: EPUB path builds chapters via `make_chapter_xhtml`, calls `make_epub`, writes file, calls `upsert_book(storage_type='file')`; DB path unchanged
- `done` SSE event includes `storage_type`; `conversion.js` updates the download button label/action accordingly
- EPUB → DB conversion: fixed double chapter title
- `_epub_body_inner` strips the first `<h1>`/`<h2>`/`<h3>` heading from each chapter body before storing; the editor prepends its own heading, so storing the EPUB heading too caused it to appear twice
- Fix for `NavigableString` crash: `getattr(child, "name", None) is None` used instead of `hasattr(child, "name")``NavigableString` has `name = None` but no `decompose()` method
- Sidebar: Search link styling fixed
- Stray `<li>Search</li>` moved inside the Library `<ul class="sidebar-nav">` (was outside, causing incorrect HTML structure)
- `sidebar.css`: added `a:visited { color: var(--text-dim) }` and `a.active:visited { color: var(--accent) }` to prevent the browser's default purple visited color
## 2026-04-03 (2)
- DB-stored books (Fase 46): EPUB→DB conversion, DB→EPUB export, full-text search
- **Fase 4** — EPUB-to-DB conversion: `POST /api/library/convert-to-db/{filename}` converts an existing on-disk EPUB to a DB-stored book; extracts chapters via `_epub_body_inner` (rewrites img src to imagestore URLs), migrates all child rows (book_tags, reading_progress, reading_sessions, bookmarks, library_cover_cache) to the new `db/…` filename using INSERT→UPDATE→DELETE to respect FK constraints, then deletes the EPUB file
- **Fase 5** — DB→EPUB export: `GET /api/library/export-epub/{filename}` builds and streams an EPUB from DB content; `_rewrite_db_images_for_epub` rewrites `/library/db-images/…` URLs back to `OEBPS/Images/…` paths, deduplicating by sha256; `Content-Disposition: attachment` response
- **Fase 6** — Full-text search: new `routers/search.py` with `GET /search` (page) and `GET /api/search?q=…` (FTS over `book_chapters.content_tsv` via `plainto_tsquery('simple', q)`, `ts_headline` for snippets, `ts_rank` for ordering, LIMIT 30, excludes archived); new `templates/search.html` with highlight (`<mark>`), "Read here" link (`?bm_ch=N&bm_scroll=0`), and "Book detail" link; Search entry added to sidebar
- `book.html`: DB books show "Export EPUB" instead of "Download"; "Edit EPUB" and "Convert to DB" buttons only shown for `.epub` files; delete modal text differs for DB vs file books
- `PATCH /library/book/{filename}`: DB book branch added — skips file move, recomputes synthetic `db/…` filename via `make_rel_path`, applies same FK-safe rename pattern, updates `book_chapters` and `bookmarks` in addition to standard child tables
## 2026-04-03 (1)
- DB-stored books (Fase 13): grabber now stores scraped books in PostgreSQL instead of EPUB files on disk
- New `book_chapters` table: `filename FK, chapter_index, title, content TEXT, content_tsv TSVECTOR`; GIN index on `content_tsv` for future FTS
- New `book_images` table: `sha256 PK, ext, media_type, size_bytes`; content-addressed imagestore at `library/images/{sha2}/{sha256}{ext}`
- New `storage_type VARCHAR(10) DEFAULT 'file'` column on `library`; DB-stored books use `'db'`
- New utilities in `common.py`: `is_db_filename`, `write_image_file`, `store_db_image`, `html_to_plain`, `upsert_chapter`, `ensure_unique_db_filename`; `make_rel_path` now handles `media_type="db"` → synthetic `db/{pub}/{auth}/...` filename
- `upsert_book` and `list_library_json` updated to include `storage_type`
- Grabber: `_run_scrape` stores chapters in `book_chapters`, chapter images in imagestore (absolute `/library/db-images/` URLs embedded in HTML), cover in `library_cover_cache`; no EPUB file written
- New `GET /library/db-images/{path:path}` endpoint serves imagestore files
- Reader: `GET /library/chapters/` and `GET /library/chapter/` have DB branches for `storage_type='db'` books (query `book_chapters` directly)
- Reader page (`/library/read/`), book detail page, mark-read, and rating endpoints all handle DB filenames (no file existence required)
- Cover endpoints (`/library/cover/`, `/library/cover-cached/`) serve DB books from `library_cover_cache`
## 2026-04-02 (1)
- Added Restore functionality to the Backup page
- New `GET /api/backup/snapshots` endpoint: lists available Dropbox snapshots (name + date parsed from filename, no downloads needed)
- New `GET /api/backup/snapshots/{snapshot_name}/files` endpoint: loads a snapshot from Dropbox and returns all files with path, size, sha256, and whether the file currently exists locally
- New `POST /api/backup/restore` endpoint: downloads file objects from Dropbox, writes to disk, and re-indexes via `scan_media` + `upsert_book`; returns per-file result with errors
- New "Restore" card on the backup page: snapshot dropdown (auto-loaded on page open), file list with filter/search, per-file "Restore" button, multi-select + "Restore selected", on-disk indicator, inline status feedback
- After restore, the file list refreshes to reflect updated on-disk state
## 2026-03-29 (10)
- Duplicates: fixed `updateCounts` crashing with a TypeError (`g.books.length` → `g.length`); the crash prevented `renderGrid` from running, so the duplicates view never rendered and the counter was stale
## 2026-03-29 (9)
- Duplicates: volume-aware duplicate detection — a book is only a duplicate when title + author + volume all match
- `_duplicateGroups` in `library.js` (Duplicates view): key now includes `series_index` when > 0, so different volumes of the same series are no longer grouped as duplicates
- `preload` in `grabber.py` (Grabber): when the scraper returns a `series_index_hint`, the DB lookup only flags a match when title + author + volume all match; falls back to title + author when no volume is known — same logic as the bulk importer
## 2026-03-29 (8)
- Shared code: eliminated all remaining duplication across templates and JS files
- CSS custom properties: extracted single `:root { }` block into `static/theme.css`; removed duplicate inline `:root` from all 15 templates and from `library.css`, `book.css``editor.css` keeps editor-specific vars (`--danger`, `--header-h`, `--panel-w`), `reader.html` keeps page-specific vars (`--header-h`, `--footer-h`, `--content-w`), `backup.html` keeps (`--ok`, `--warn`, `--err`)
- Cover helpers: moved `strHash`, `COVER_PALETTES`, `makePlaceholderCover`, `wrapText`, `truncate` from `library.js` and `book.js` into `books.js`; removed from `home.html` and `following.html`
- HTML escape: `esc()` added to `books.js`; removed from `library.js`, `editor.js`, and all 8 templates that defined it inline
- SSE/EventSource: extracted shared `connectConversionStream(job_id)` and `addLog()` into new `static/conversion.js`; both `index.html` and `grabber.html` now call the shared function (removed ~70 duplicate lines)
## 2026-03-29 (7)
- Search: extracted shared book helpers and search logic into `static/books.js`
- `_filenameBase`, `bookTitle`, `bookAuthor`, `tagValuesByType`, `bookGenres`, `bookSubgenres`, `bookPlainTags`, `filterBooks`, `setupSearchInput` moved from `library.js` and `home.html` to a single shared file
- Both pages now use identical search behaviour: Enter to search, × to clear
- Both pages now search across title, author, publisher, genre, sub-genre, and tag
## 2026-03-29 (6)
- Search: changed from search-as-you-type (250 ms debounce) to Enter-to-search on both Library and Home — prevents iPad keyboard from locking up on large collections
## 2026-03-29 (5)
- Accent colour updated to match logo orange (`#ffa20e`); secondary accent updated to `#ffb840`
- Applied across all CSS files, templates, and inline styles
## 2026-03-29 (4)
- Branding: added logo, favicon, and Apple touch icon to all pages
- Static assets: `logo.png` (sidebar), `favicon.ico` (16×16), `favicon-32.png` (32×32), `favicon-256.png` (256×256), `apple-touch-icon.png` (180×180 with `#0f0e0c` background)
- Favicon `<link>` tags added to all 15 templates
- Sidebar: image logo (`logo.png`) placed next to the existing "No**vela**" wordmark using flexbox
- `apple-touch-icon.png` uses dark `#0f0e0c` background — renders as a native-looking iOS home screen icon
## 2026-03-29 (3)
- Dockerfile: replaced `unrar-free` with proprietary `unrar` (RARLAB v6.2.6) from Debian non-free — fixes "Failed to read enough data" errors on RAR archives using newer compression methods
## 2026-03-29 (2)
- CBR reader: detect archive format via magic bytes instead of file extension — `.cbr` files that are actually ZIP or 7-zip archives now open correctly; added `py7zr` dependency for 7-zip support
## 2026-03-29 (1)
- Bulk Import: duplicate check now volume-aware — books with the same title+author but a different volume number (e.g. recoloured reprints) are no longer flagged as duplicates; `volume` is included in the API call and matched against `series_index` in the DB
## 2026-03-28 (11)
- Bulk Import: duplicate detection against existing library
- New `POST /api/bulk-check-duplicates` endpoint: case-insensitive title+author+volume match, single SQL query with OR conditions for all pairs
- Duplicate rows highlighted in red in the preview table; a skip checkbox appears per duplicate row
- Stats bar shows "X duplicates · Skip all · Import all" action buttons
- Duplicate rows are skipped by default; user can toggle individual rows or use bulk actions
- `startImport()` filters out skipped rows before batching; skipped files appear in the result summary as "Duplicate skipped"
- If all rows are skipped (nothing to import), result shown immediately without sending any request
## 2026-03-28 (10)
- After "Remove from New" succeeds, the library is now reloaded from the server (`loadLibrary()`) so the New list updates immediately without a manual refresh
## 2026-03-28 (9)
- Bulk delete is now batched: new `POST /library/bulk-delete` endpoint accepts a JSON list of filenames, deletes files and removes DB rows in one query per batch; JS sends 20 files per batch with a progress bar in the confirmation dialog
## 2026-03-28 (8)
- Disk usage warning in sidebar: `GET /api/disk` returns partition usage for the library directory; sidebar polls every 60 s and shows a warning bar (amber ≥ 85% or < 2 GB free, red 95% or < 500 MB free) above the backup status bar
## 2026-03-28 (7)
- Fixed `mark-reviewed` JS: UI (allBooks update + renderGrid) now only runs after confirmed server success; `catch` no longer fires a false error after a successful response where renderGrid throws
## 2026-03-28 (6)
- Bulk Import: rewrote pattern editor from token-pills to free-text `%placeholder%` syntax
- Pattern input: free-text field; placeholders: `%series%` `%volume%` `%title%` `%year%` `%month%` `%day%` `%author%` `%publisher%` `%ignore%`
- Colored chip row: click to insert at cursor position; drag onto input also supported
- Colored live preview below the pattern input; regex-based parser replaces delimiter+token logic
- Shared metadata now wins over filename-parsed values (previously filename won)
- All UI text translated from Dutch to English
## 2026-03-28 (5)
- Status badge (top-right cover) and want-to-read star (top-left cover) now use dark fill `rgba(15,14,12,0.82)` + `box-shadow: 0 0 0 2px #0f0e0c` ring — always readable regardless of cover colour
## 2026-03-28 (4)
- Added `Temporary Hold` status; renamed `Hiatus``Long-Term Hold`
- Startup migration `migrate_rename_hiatus()` converts existing DB records automatically
- Status colours: Complete=green, Ongoing=blue, Temporary Hold=amber, Long-Term Hold=orange
- `statusBadgeHtml()` helper in `library.js` replaces three identical inline badge blocks
- Grabber: `Temporary-Hold` (gayauthors.org) now maps to `Temporary Hold`; `Long-Term Hold` passes through unchanged
- Status dropdowns updated in Book Detail and Bulk Import
## 2026-03-28 (3)
- Added Bulk Import page (`/bulk-import`) for batch importing CBR/CBZ/EPUB/PDF files with filename-based metadata parsing
- Configurable token pattern: Serie, Volume, Titel, Jaar, Auteur, Uitgever, Negeer (in any order)
- Configurable delimiter (default ` - `); year token right-anchored when last so title absorbs overflow segments
- Live test-parse box: type any filename and see how it parses before selecting files
- Shared metadata card: author, publisher, status, genres, tags applied to all files (overridden by pattern tokens or manual edits)
- Preview table: all parsed fields editable via contenteditable cells; warning indicator for rows with fewer segments than tokens; sorted by filename
- Batch upload in groups of 5 with progress bar (N / total files processed)
- Result summary with list of skipped files and reasons
- New `routers/bulk_import.py`: `GET /bulk-import`, `POST /library/bulk-import`
- Sidebar link under Tools between Book Builder and Credentials
This file tracks changes on the `develop` line. This file tracks changes on the `develop` line.
`changelog.md` can later be used for release summaries. `changelog.md` can later be used for release summaries.
## 2026-03-28 (2)
- Performance: library page now loads instantly for large collections (1000+ books)
- `IntersectionObserver` defers both cover image loading and placeholder canvas drawing until cards enter the viewport — eliminates hundreds of upfront canvas ops that blocked the initial render
- `ETag` caching on `/library/list`: server returns `304 Not Modified` when nothing changed, client skips JSON parse and re-download
- Single DOM pass in `renderBooksGrid`, `renderDuplicatesView`, `renderSeriesDetail`: canvas and img set up via `card.querySelector` immediately after `innerHTML`, removing a second iteration with `document.getElementById` per card
- `book_tags` joined via `json_agg` in the main `list_library_json()` query, eliminating a separate `SELECT * FROM book_tags` query and Python merge loop
- `loadLibrary` now shows an error message instead of staying stuck on "Loading…" when the fetch or render fails
## 2026-03-28 (1)
- Added Following page (`/following`): track external author URLs outside Library and Tools
- New `authors` table: `name` (unique), `url`, `created_at`, `updated_at`
- New `routers/following.py`: `GET /following` page, `GET /api/following` (all authors + URL + book count + last added), `POST /api/following/{name}` (set/clear URL)
- Sidebar: new Following section between Library and Tools; counter shows number of followed authors
- Following page: two tabs — Following (authors with URL) and All Authors; inline URL editing with Enter/Escape keyboard support; Visit button opens external URL in a new tab; author name links to library author view
- Added Incomplete view to Library (`#incomplete`): shows all non-archived books where `publication_status ≠ Complete`; sidebar counter included; entry placed after New in the Library section
## 2026-03-27 (1)
- Convert page: duplicate warning shown after loading metadata when a book with the same title+author already exists in the library; warning includes a link to the existing book; user can still proceed with conversion
- Library: added Duplicates section to sidebar (between Rated and Statistics); counter shows total number of books that are part of a duplicate group (same title+author, case-insensitive); Duplicates view groups books by title+author with a subheading per group
- Fixed Duplicates view not loading covers: card renderer now uses the same canvas + two-pass img/`makePlaceholderCover` pattern as `renderBooksGrid`
## 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
## 2026-03-25 (19)
- Fixed series index 0 not displaying in series slot view, grid cards, list volume column, and book detail:
- Series slot labels now show `#0` for all slots when the series has at least one positively-indexed sibling (consistent label height across all cards)
- Grid card and list volume column use the same cross-book heuristic (`indexedSeriesSet`) to show `[0]` where appropriate
- Book detail page queries whether any sibling in the same series has `series_index > 0` (`series_is_indexed`) and shows `[0]` accordingly
## 2026-03-25 (18)
- Added autocomplete for Author, Publisher, and Series in the book edit panel: typing shows a filtered dropdown of existing values from the library; selecting a value fills the field (same dropdown styling as genres/tags)
- Status field now defaults to "Complete" when opening the edit panel for a book that has no status set yet
## 2026-03-25 (17)
- Fixed CBR reader showing only first page: `cbr_page_count` was missing from the `cbr` import in `reader.py`; `/api/cbr/info/` was returning an error, causing `page_count` to fall back to 1 and the Next button to remain disabled
## 2026-03-25 (16)
- Fixed CBR/CBZ reader stuck on loading: added `/api/cbr/info/{filename}` endpoint returning `{page_count}`, and added a CBR/CBZ branch in `reader.html` that mirrors the PDF paged reader (page images served via `/library/cbr/{filename}/{page}`)
## 2026-03-25 (15)
- Added series volume 0 and letter suffix support (e.g. "21a", "21b"):
- New `series_suffix VARCHAR(10)` column added to `library` table via `migrate_series_suffix`
- `series_index` lower bound changed from 1 to 0 throughout (index 0 = special/prequel edition)
- Volume field in the book editor changed from `type="number"` to `type="text"` — accepts "0", "1", "21a", etc.
- Server parses the combined volume string into `series_index` (INTEGER) + `series_suffix` (VARCHAR) via `parse_volume_str`
- File naming includes suffix: `021a - Title.epub`
- `novela:series_suffix` meta tag written to/read from EPUB OPF for persistence across rescans
- Series detail view: books sorted by `(series_index, series_suffix)`; slot labels show "21a"; index 0 shown as slot when any sibling has index > 0
- Grid cards, list view Volume column, author/publisher sort all include suffix
## 2026-03-25 (14)
- Added multi-select and bulk delete to `All books` List view:
- Checkbox column added to the list table (List mode only; Grid mode unchanged)
- Select all / Clear all / Clear selection controls in the controls bar
- `Shift+click` range selection on checkboxes
- `Delete selected` button (red, disabled when nothing is selected) triggers a confirmation dialog; confirmed deletions remove files from disk and database, then reload the library
## 2026-03-25 (13)
- Fixed EPUB cover replacement reverting to original after upload:
- `GET /library/cover/{filename}` for EPUBs now checks the DB cover cache first (populated on upload) before falling back to extracting from the EPUB file; the cache is also warmed on cache-miss so subsequent requests are fast.
- `add_cover_to_epub` fully rewritten: locates the OPF via `META-INF/container.xml`, finds the existing cover image in the OPF manifest, removes it from the ZIP, and writes the new cover in the same directory — works for any EPUB directory structure (`OEBPS/`, `EPUB/`, etc.).
## 2026-03-25 (12)
- Fixed chapter images not loading in EPUBs that use a non-standard root directory (e.g. `EPUB/` instead of `OEBPS/`): image paths are now passed as full ZIP paths instead of stripping the root segment, and the image endpoint no longer hardcodes an `OEBPS/` prefix. Case-insensitive fallback retained for EPUBs with mismatched image folder casing.
## 2026-03-25 (11)
- Fixed reader progress bar jumping to 100% immediately when navigating to the second chapter in short EPUBs (2 chapters): changed progress formula denominator from `total - 1` to `total` so 100% is only reached after fully scrolling through the last chapter. Same fix applied to PDF page progress.
## 2026-03-25 (10)
- Added bookmark feature:
- `bookmarks` table in DB (`migrate_create_bookmarks`): `filename`, `chapter_index`, `scroll_frac`, `chapter_title`, `note`, `created_at`
- New API endpoints: `GET/POST /library/bookmarks/{filename}`, `PATCH/DELETE /library/bookmarks/{id}`, `GET /api/bookmarks`
- Bookmark button in reader header (orange, bookmark icon): opens modal with optional note field
- Bookmark modal closes on Escape, backdrop click, or Cancel
- Reader supports `?bm_ch=N&bm_scroll=F` URL params to jump directly to bookmarked position (overrides saved progress)
- Library sidebar: Bookmarks section in Library nav with live count badge
- Library `#bookmarks` view: card list showing cover, book title, author, chapter, note, date, Go-to and Delete buttons
## 2026-03-22 ## 2026-03-22
- Added blueprint/technical documentation structure in `docs/`. - Added blueprint/technical documentation structure in `docs/`.
- Completed router split and bootstrap structure (`main.py`, routers, migrations, DB pool). - Completed router split and bootstrap structure (`main.py`, routers, migrations, DB pool).
@ -45,3 +288,81 @@ This file tracks changes on the `develop` line.
- dry-run support in the new flow - dry-run support in the new flow
- Updated Docker image with `postgresql-client` for `pg_dump`. - Updated Docker image with `postgresql-client` for `pg_dump`.
- Multiple test builds pushed to `gitea.oskamp.info/ivooskamp/novela:dev`. - Multiple test builds pushed to `gitea.oskamp.info/ivooskamp/novela:dev`.
## 2026-03-25 (9)
- Fixed backup progress not visible immediately after clicking Run Live Backup: sidebar `loadBackupProgress()` is now called directly from the backup page after a successful start
- Fixed backup status timestamp showing wrong relative time (e.g. "1h ago" for a recent backup): server UTC timestamps without timezone suffix are now correctly interpreted as UTC in the browser
- Fixed backup status bar using browser-default purple link color: now uses `var(--accent)` orange consistent with the rest of the UI
- Added password manager ignore attributes to App Key and App Secret fields: `data-1p-ignore` (1Password), `data-lpignore="true"` (LastPass), `data-form-type="other"` (1Password v8)
## 2026-03-25 (8)
- Added Dropbox OAuth2 refresh token support (no redirect URI needed):
- New endpoints: `POST /api/backup/oauth/prepare` (generates auth URL) and `POST /api/backup/oauth/exchange` (exchanges code for refresh token)
- `_dbx()` now prefers refresh token mode (app key + secret + refresh token) with automatic renewal; falls back to legacy access token for backwards compatibility
- App key and secret stored encrypted in `credentials` table (`dropbox_app_key`, `dropbox_app_secret`)
- `DELETE /api/backup/credentials` now also cleans up `dropbox_app_key` and `dropbox_app_secret`
- Backup page updated with two-step OAuth flow UI (app key/secret → auth URL → paste code)
- Settings status shows `• refresh token` or `• legacy token` indicator
## 2026-03-25 (7)
- Added backup progress tracking: `GET /api/backup/progress` returns `{running, done, total, phase}`; sidebar shows live file count (e.g. `312 / 652 · uploading`) polling every 3 s while running
## 2026-03-25 (6)
- Fixed `GET /api/backup/health` missing `schedule_enabled` and `schedule_interval_hours` in response (values were read but not returned; Health card showed "disabled" even when schedule was on)
- Added backup status indicator in sidebar above Rescan library button: coloured dot + status text (OK/running/failed/never), links to `/backup`, auto-refreshes every 8 s while running, shows time-ago for last successful backup
## 2026-03-25 (5)
- One-time path migration: all 571 existing library files moved to correct format-prefixed structure (`epub/`, `pdf/`, `comics/`) and all DB references updated (`migrate_paths.py --execute`)
- Empty old publisher root directories pruned after migration
- Recovery script (`recover_decock049.py`) written; confirmed file was added after last Dropbox backup so not recoverable — to be re-imported manually
## 2026-03-25 (4)
- Added {publisher} directory to PDF and CBR/CBZ paths: `pdf/{publisher}/{author}/{title}.pdf`, `comics/{publisher}/{author}/{title}.cbr/cbz` — consistent with EPUB structure
## 2026-03-25 (3)
- Fixed CBZ extension in import: `common.make_rel_path` always generated `.cbr` for CBZ files; now accepts `ext` parameter; `library.py` passes actual suffix so CBZ files land at `comics/{author}/{title}.cbz`
- Added missing `GET /download/{filename}` endpoint (referenced in book.html but was 404)
- TECHNICAL.md fully rewritten: added File Storage Paths section, complete endpoint lists for all routers including settings.py, corrected path documentation
## 2026-03-25 (2)
- Fixed PDF metadata editing (PATCH /library/book):
- `_sync_epub_metadata` is now only called for `.epub` files; PDFs update DB only
- `_make_rel_path` now includes the format prefix matching import: EPUB → `epub/{publisher}/{author}/…`, PDF → `pdf/{author}/{title}.pdf`, CBR/CBZ → `comics/{author}/{title}{ext}`; previously files were moved outside their format directory on metadata save
- Fixed PDF reader (infinite loading screen):
- `reader_page` now passes `format` (epub/pdf/cbr/cbz) to `reader.html`
- Added `GET /api/pdf/info/{filename}` endpoint returning `{"page_count": N}`
- `reader.html` branches on `FORMAT`: PDFs render page images from `/library/pdf/{filename}?page=N`, EPUB flow unchanged
- PDF progress tracked per page; keyboard and button navigation work identically to EPUB
- `Edit EPUB` button in Book Detail hidden for non-EPUB files
## 2026-03-23
- Added `All books` Grid/List toggle in Library:
- same columns as `New` view (Publisher, Author, Series, Volume, Title, Has cover, Updated, Genres, Sub-genres, Tags, Status)
- column visibility filter in `List` mode
- no selection checkboxes or bulk actions
- view mode and column visibility persisted separately in `localStorage` (`novela.all.viewMode`, `novela.all.visibleColumns`)
- Added 15 star rating for books:
- stored in the database (`rating SMALLINT DEFAULT 0`)
- written to EPUB OPF as `<meta name="novela:rating" content="N"/>` on rating change
- written to CBZ `ComicInfo.xml` as `<NovelaRating>N</NovelaRating>` on rating change
- CBR and PDF are DB-only (file format constraints)
- rating is recovered from file metadata during rescan/DB rebuild (`upsert_book` preserves file rating over default 0)
- stars displayed under the cover (outside `.book-info`) in all grid views (Library, Home)
- stars in grid cards are display-only (no click) to prevent accidental taps while scrolling
- stars in Book Detail are interactive (larger, 1.1rem), clicking same star removes rating
- star color: amber (`#c8a03a`) for filled, `rgba(200, 160, 58, 0.25)` for unfilled — consistent across Library, Home, and Book Detail
- Added text colour setting to reader hamburger menu:
- 5 warm-tone presets from bright (`#e8e2d9`) to dim (`#938d86`)
- active preset shown with accent-coloured ring
- choice persisted in `localStorage` (`reader-text-colour`) and restored on next open
- Increased spacing between hamburger button and back link in reader header (`margin-left: 1rem`) to prevent accidental taps
- Removed `Cover Missing` auto-tag:
- tag is no longer added on import, rescan, or grabber download
- `ensure_cover_missing_tag()` removed from `common.py`, `library.py`, and `grabber.py`
- startup migration removes all existing `Cover Missing` tags from the database
- Fixed Tags/Genres/Sub-Genres not saving in book edit panel on desktop:
- `,` (comma) now acts as a confirmation key alongside Enter in `PillInput`
- `flush()` added to `PillInput`: any text still in the input field is auto-confirmed when Save is clicked
- Fixed tag/genre search and tag-pill navigation being broken:
- `renderGenreView` was filtering on `b.genres` (non-existent field); now uses `bookGenres()`, `bookSubgenres()`, `bookPlainTags()`
- `renderSearchResults` had the same bug; search now covers title, author, genres, sub-genres, and tags

121
docs/changelog.md Normal file
View File

@ -0,0 +1,121 @@
# Changelog
## v0.1.3 — 2026-04-03
### New feature
- DB-stored books: scraped books are now stored as chapters in PostgreSQL instead of EPUB files on disk — full-text search, content deduplication, and backup coverage are all handled automatically
- Grabber stores chapters in a `book_chapters` table and images in a content-addressed imagestore (sha256-based, automatic deduplication)
- EPUB → DB conversion: "Convert to DB" button on any EPUB book detail page — extracts chapters, migrates all metadata and child rows (tags, progress, bookmarks, cover), removes the EPUB file
- DB → EPUB export: "Export EPUB" button on DB-stored books — builds and streams a standards-compliant EPUB without writing a file to disk
- Full-text search (`/search`): searches across all DB-stored chapter content via PostgreSQL FTS (`tsvector` / `plainto_tsquery`), returns highlighted snippets with direct links to the chapter position in the reader
- Chapter editor supports DB-stored books: Monaco-based editor reads and writes `book_chapters` directly; chapter titles editable inline; title-only changes correctly included in Save All
- Grabber: storage toggle on the Convert page — choose between DB storage and EPUB file before converting
---
## v0.1.2 — 2026-04-02
### New feature
- Restore functionality on the Backup page: browse any available Dropbox snapshot, see which files are currently missing from disk, and restore individual books or a selection back to the library — file is written to disk and immediately re-indexed
---
## v0.1.1 — 2026-03-31
Bug fixes, volume-aware duplicate detection, shared code cleanup, and a new Changelog page.
### Bug fixes
- Duplicates view crashed on load due to a TypeError (`g.books.length` was undefined); counter was stale and the view never rendered
- Duplicate detection was too aggressive: different volumes of the same series (same title + author, different volume) were incorrectly grouped as duplicates — now keyed on title + author + volume
- Grabber preload: same volume-aware fix — only flags a duplicate when title, author, and volume all match; falls back to title + author when no volume is known
- Bulk Import duplicate check: different volumes of the same series are no longer flagged as duplicates
### Improvements
- Search changed from search-as-you-type (250 ms debounce) to Enter-to-search — prevents the iPad keyboard from locking up on large collections
- CBR reader: archive format now detected via magic bytes instead of file extension — `.cbr` files that are actually ZIP or 7-zip archives open correctly; added 7-zip support via `py7zr`
- Docker: replaced `unrar-free` with proprietary unrar (RARLAB v6.2.6) — fixes failures on RAR archives using newer compression methods
### New feature
- Changelog page (`/changelog`): structured release history with version, date, and categorised change lists
### Code quality
- Shared CSS (`theme.css`): single `:root` block with all global CSS custom properties; loaded on every page — no more duplicate inline `:root` blocks across templates
- Shared JS (`books.js`): book helpers (`bookTitle`, `bookAuthor`, `bookGenres`, `bookSubgenres`, `bookPlainTags`, `filterBooks`) and search input wiring extracted into one shared file
- Shared JS (`conversion.js`): SSE/EventSource logic (`connectConversionStream`, `addLog`) extracted from Convert and Grabber pages into one shared file
---
## v0.1.0 — 2026-03-29
First release of Novela: a self-hosted personal library for EPUB, PDF, CBR, and CBZ files.
### Library
- Grid and List view for all books and New books, with column visibility filter and persistent view mode
- Sidebar navigation: All books, Want to Read, New, Incomplete, Series, Authors, Publishers, Archived, Bookmarks, Rated, Duplicates, Statistics
- Sidebar counters for all sections, live-updated without page reload
- 15 star ratings stored in DB and written back to EPUB OPF / CBZ ComicInfo.xml
- Publication status: Complete, Ongoing, Temporary Hold, Long-Term Hold
- Status and want-to-read badges on grid covers, always readable regardless of cover colour
- Duplicate detection: groups books by title+author; counter in sidebar
- Incomplete view: all non-archived books where publication status is not Complete
- Rated view: non-archived books with a star rating, sorted by rating
- Bulk delete in All books List view with multi-select and Shift+click range selection
- Disk usage warning in sidebar (amber ≥ 85%, red ≥ 95% or low free space)
- Autocomplete for Author, Publisher, and Series in the book edit panel
- Series volume suffix support (e.g. "21a", "21b") and volume 0 for prequels/specials
- Cover upload for EPUB books; cover cache for fast subsequent loads
### Reader
- EPUB reader with chapter navigation, scroll progress, and bookmarks
- PDF reader with page-image rendering and page navigation
- CBR/CBZ reader with page-image rendering; format detection via magic bytes (supports ZIP, RAR, 7-zip archives)
- Reader text colour: 5 warm-tone presets, persisted per browser
- Content width slider (30100 vw), persisted per browser
- Bookmarks: save position with optional note; navigate back via sidebar or bookmark list
### Import & Convert
- Single-file import: drag-and-drop or file picker for EPUB, PDF, CBR, CBZ
- Bulk Import (`/bulk-import`): batch import with `%placeholder%` filename pattern parsing, shared metadata, live preview table, and duplicate detection
- Convert (`/convert`): scrape web fiction and convert to EPUB; warns if title+author already exists in library
- Grabber with credentials manager for site-specific login
### Book Builder
- Create EPUB books from scratch via a WYSIWYG editor (`/builder`)
- Chapters with contenteditable editing; toolbar: bold, italic, underline, blockquote, author note, scene break, normalize
- Autosave every 30 s and Ctrl+S; publish produces a standards-compliant EPUB 2.0 added directly to the library
### Following
- Following page (`/following`): track external author URLs
- Two tabs: Following (authors with URL set) and All Authors
- Inline URL editing with Enter/Escape support; Visit opens URL in new tab
- Sidebar counter shows number of followed authors
### Backup
- Dropbox backup with versioned snapshots and object-store deduplication
- OAuth2 refresh token flow (does not expire); legacy access token supported as fallback
- Configurable backup root, snapshot retention, and scheduled interval
- Live backup progress in sidebar (file count + phase); backup status dot with time-ago
- PostgreSQL dump included in each backup run
### Performance
- Library loads instantly for large collections: ETag `304 Not Modified`, `IntersectionObserver` lazy covers, single DOM pass rendering, `json_agg` SQL tag aggregation
- Fast-path `/api/library` (DB-only); full rescan only on demand
### Branding
- Favicon for browser tabs (16×16, 32×32, 256×256)
- Apple touch icon (180×180) with dark background for iOS home screen
- Logo in sidebar alongside the Novela wordmark

1
version.txt Normal file
View File

@ -0,0 +1 @@
v0.1.3