Compare commits
31 Commits
e60b86ea7e
...
f8bae60ac7
| Author | SHA1 | Date | |
|---|---|---|---|
| f8bae60ac7 | |||
| d8d30fb00d | |||
| 26c6c151c6 | |||
| f4ac7a7662 | |||
| 9b7ac7213d | |||
| 25ececc576 | |||
| ef3c28b141 | |||
| eb4136afc7 | |||
| 32bf4a4d83 | |||
| e4d2e2c636 | |||
| 4ecbfafc86 | |||
| fda690596c | |||
| fab7591f38 | |||
| e1aca546a0 | |||
| fb8311fb3f | |||
| b0cb365f98 | |||
| b70379c5b9 | |||
| b9b7fb3009 | |||
| 0a46e1c13d | |||
| d012fa239e | |||
| 5dfc99e8c0 | |||
| 6d2ca2eeb2 | |||
| b43366723c | |||
| 3d739b4c72 | |||
| 5d83bfccab | |||
| 00e75a6106 | |||
| 2d672ff7bc | |||
| 1b885e1873 | |||
| 39eef0a388 | |||
| f3f9d45d2b | |||
| 92cd301658 |
77
README.md
77
README.md
@ -1,20 +1,65 @@
|
||||
# Novela
|
||||
|
||||
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
|
||||
- Library import and indexing for EPUB/PDF/CBR/CBZ
|
||||
- Home dashboard with continue reading and unread/read sections
|
||||
- Reader support for EPUB, PDF, and comics (CBR/CBZ)
|
||||
- Metadata editing (title, author, publisher, series, volume, tags, genres)
|
||||
- `New` review workflow with list/grid view, column toggles, and bulk actions
|
||||
- Reading analytics/statistics dashboard
|
||||
- Dropbox backup with:
|
||||
- versioned snapshots
|
||||
- object deduplication
|
||||
- retention policy
|
||||
- scheduled background runs
|
||||
|
||||
### Library
|
||||
- Import and indexing for EPUB, PDF, CBR/CBZ
|
||||
- Drag-and-drop import from library page or home page
|
||||
- Cover extraction and caching (EPUB, PDF first page, CBR/CBZ first page); manual cover upload for EPUB
|
||||
- Metadata editing: title, author, publisher, series, volume, tags, genres, sub-genres, star rating, publication status
|
||||
- Publication statuses: Complete, Ongoing, Temporary Hold, Long-Term Hold
|
||||
- Want-to-read flag and archived flag
|
||||
- 1–5 star ratings (stored in EPUB OPF / CBZ ComicInfo.xml / DB)
|
||||
- Download individual files
|
||||
|
||||
### 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
|
||||
- FastAPI
|
||||
@ -23,10 +68,10 @@ It supports EPUB, PDF, and CBR/CBZ, with metadata editing, reading progress trac
|
||||
- Docker / Docker Compose style deployment
|
||||
|
||||
## Repository Layout
|
||||
- `containers/novela/` - application code (routers, templates, static assets, migrations)
|
||||
- `stack/` - deployment stack files and environment configuration
|
||||
- `docs/` - technical status and changelog documentation
|
||||
- `build-and-push.sh` - helper script for container build/push
|
||||
- `containers/novela/` — application code (routers, templates, static assets, migrations)
|
||||
- `stack/` — deployment stack files and environment configuration
|
||||
- `docs/` — technical status and changelog documentation
|
||||
- `build-and-push.sh` — helper script for container build/push
|
||||
|
||||
## Quick Start (Development)
|
||||
1. Configure environment values in `stack/novela.env`.
|
||||
|
||||
@ -3,14 +3,14 @@ set -euo pipefail
|
||||
|
||||
# ============================================================================
|
||||
# build-and-push.sh
|
||||
# Location: repo root (e.g. /docker/develop/novela)
|
||||
# Location: repo root
|
||||
#
|
||||
# Purpose:
|
||||
# - Automatic version bump:
|
||||
# 1 = patch, 2 = minor, 3 = major, t = test
|
||||
# - Test builds: only update :dev (no commit/tag)
|
||||
# - 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
|
||||
# - Summary: show all images + tags built and pushed
|
||||
# - Branch visibility:
|
||||
@ -120,7 +120,7 @@ if [[ ! -d ".git" ]]; then
|
||||
fi
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
@ -235,10 +235,11 @@ for svc_path in "${services[@]}"; do
|
||||
echo "============================================================"
|
||||
echo "[INFO] Building ${svc} -> tags: ${NEW_VERSION}, latest"
|
||||
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}:latest"
|
||||
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
|
||||
echo "============================================================"
|
||||
echo "[INFO] Test build ${svc} -> tag: latest"
|
||||
|
||||
@ -2,10 +2,11 @@ FROM python:3.12-slim
|
||||
|
||||
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 \
|
||||
libmagic1 \
|
||||
unrar-free \
|
||||
unrar \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@ -2,21 +2,39 @@ from io import BytesIO
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
import py7zr
|
||||
import rarfile
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
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:
|
||||
return path.suffix.lower() == ".cbz"
|
||||
def _detect_format(path: Path) -> str:
|
||||
"""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]:
|
||||
if _is_cbz(path):
|
||||
fmt = _detect_format(path)
|
||||
if fmt == "zip":
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
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:
|
||||
with rarfile.RarFile(path) as rf:
|
||||
names = [n for n in rf.namelist() if Path(n).suffix.lower() in SUPPORTED_IMG]
|
||||
@ -42,9 +60,15 @@ def cbr_get_page(path: Path, page_num: int) -> tuple[bytes, str]:
|
||||
"bmp": "image/bmp",
|
||||
}.get(ext, "image/jpeg")
|
||||
|
||||
if _is_cbz(path):
|
||||
fmt = _detect_format(path)
|
||||
if fmt == "zip":
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
return zf.read(name), mime
|
||||
elif fmt == "7z":
|
||||
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
|
||||
|
||||
|
||||
168
containers/novela/changelog.py
Normal file
168
containers/novela/changelog.py
Normal 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",
|
||||
"1–5 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 (30–100 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",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@ -1,7 +1,9 @@
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from html import escape as he
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def detect_image_format(data: bytes, base: str) -> tuple[str, str]:
|
||||
@ -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:
|
||||
"""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")
|
||||
|
||||
# Read existing zip into memory
|
||||
with open(epub_path, "rb") as f:
|
||||
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()
|
||||
with zipfile.ZipFile(io.BytesIO(original), "r") as zin, \
|
||||
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)
|
||||
|
||||
for item in zin.infolist():
|
||||
if item.filename == "mimetype":
|
||||
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)
|
||||
|
||||
if item.filename == "OEBPS/content.opf":
|
||||
data = _patch_opf(data.decode("utf-8"), cover_filename, cover_media_type).encode("utf-8")
|
||||
|
||||
if item.filename == opf_path:
|
||||
data = _patch_opf(
|
||||
data.decode("utf-8"),
|
||||
cover_filename,
|
||||
cover_media_type,
|
||||
old_cover_zip_path,
|
||||
opf_dir,
|
||||
).encode("utf-8")
|
||||
zout.writestr(item, data)
|
||||
|
||||
# Add the cover image
|
||||
zout.writestr(f"OEBPS/Images/{cover_filename}", cover_data)
|
||||
# Write the new cover image
|
||||
zout.writestr(new_cover_zip_path, cover_data)
|
||||
|
||||
with open(epub_path, "wb") as f:
|
||||
f.write(buf.getvalue())
|
||||
|
||||
|
||||
def _patch_opf(opf: str, cover_filename: str, cover_media_type: str) -> str:
|
||||
"""Insert cover into OPF manifest/metadata and remove Cover Missing dc:subject."""
|
||||
def _patch_opf(
|
||||
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
|
||||
opf = re.sub(r'\s*<dc:subject>Cover Missing</dc:subject>', '', opf)
|
||||
|
||||
# Add cover manifest item before </manifest>
|
||||
cover_item = f'<item id="cover-img" href="Images/{cover_filename}" media-type="{cover_media_type}"/>'
|
||||
# Remove existing cover manifest item(s) with id starting with "cover"
|
||||
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>')
|
||||
|
||||
# Add cover meta before </metadata>
|
||||
cover_meta = '<meta name="cover" content="cover-img"/>'
|
||||
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:
|
||||
f.write(buf.getvalue())
|
||||
|
||||
|
||||
def build_epub(
|
||||
title: str,
|
||||
author: str,
|
||||
publisher: str,
|
||||
chapters: list[dict],
|
||||
) -> bytes:
|
||||
"""Bouw een EPUB 2.0 bestand vanuit builder-data. Geeft raw bytes terug."""
|
||||
import uuid as _uuid
|
||||
book_id = str(_uuid.uuid4())
|
||||
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
|
||||
# mimetype — ongecomprimeerd als eerste entry
|
||||
mi = zipfile.ZipInfo("mimetype")
|
||||
mi.compress_type = zipfile.ZIP_STORED
|
||||
z.writestr(mi, "application/epub+zip")
|
||||
|
||||
z.writestr(
|
||||
"META-INF/container.xml",
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n'
|
||||
' <rootfiles>\n'
|
||||
' <rootfile full-path="OEBPS/content.opf"'
|
||||
' media-type="application/oebps-package+xml"/>\n'
|
||||
' </rootfiles>\n'
|
||||
'</container>\n',
|
||||
)
|
||||
|
||||
style_css = (
|
||||
"body { font-family: Georgia, serif; font-size: 1em;"
|
||||
" line-height: 1.6; margin: 1em; }\n"
|
||||
"p { margin: 0 0 0.8em 0; text-indent: 1.2em; }\n"
|
||||
"p:first-child, h1 + p, h2 + p, h3 + p { text-indent: 0; }\n"
|
||||
"h1, h2, h3 { font-weight: bold; margin: 1.2em 0 0.4em; }\n"
|
||||
"blockquote { margin: 1em 2em; padding: 0.3em 0.8em;"
|
||||
" border-left: 3px solid #aaa; }\n"
|
||||
"blockquote.author-note { font-style: italic; color: #666;"
|
||||
" border-left: 3px solid #555; margin: 1.2em 2em;"
|
||||
" padding: 0.4em 1em; font-size: 0.92em; }\n"
|
||||
"center img { display: block; margin: 1em auto; }\n"
|
||||
)
|
||||
z.writestr("OEBPS/Styles/style.css", style_css)
|
||||
|
||||
break_png_path = Path("static/break.png")
|
||||
if break_png_path.exists():
|
||||
z.write(str(break_png_path), "OEBPS/Images/break.png")
|
||||
|
||||
manifest_items: list[str] = []
|
||||
spine_idrefs: list[str] = []
|
||||
ncx_nav_points: list[str] = []
|
||||
|
||||
for i, ch in enumerate(chapters):
|
||||
ch_id = f"chapter_{i + 1:03d}"
|
||||
ch_filename = f"OEBPS/Text/{ch_id}.xhtml"
|
||||
ch_title = he(ch.get("title") or f"Hoofdstuk {i + 1}")
|
||||
ch_content = ch.get("content") or "<p></p>"
|
||||
|
||||
xhtml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"\n'
|
||||
' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n'
|
||||
'<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="nl">\n'
|
||||
"<head>\n"
|
||||
' <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>\n'
|
||||
f" <title>{ch_title}</title>\n"
|
||||
' <link rel="stylesheet" type="text/css" href="../Styles/style.css"/>\n'
|
||||
"</head>\n"
|
||||
"<body>\n"
|
||||
f" <h2 class=\"chapter-title\">{ch_title}</h2>\n"
|
||||
f" {ch_content}\n"
|
||||
"</body>\n"
|
||||
"</html>\n"
|
||||
)
|
||||
z.writestr(ch_filename, xhtml)
|
||||
|
||||
manifest_items.append(
|
||||
f' <item id="{ch_id}" href="Text/{ch_id}.xhtml"'
|
||||
f' media-type="application/xhtml+xml"/>'
|
||||
)
|
||||
spine_idrefs.append(f' <itemref idref="{ch_id}"/>')
|
||||
ncx_nav_points.append(
|
||||
f' <navPoint id="navPoint-{i + 1}" playOrder="{i + 2}">\n'
|
||||
f' <navLabel><text>{ch_title}</text></navLabel>\n'
|
||||
f' <content src="Text/{ch_id}.xhtml"/>\n'
|
||||
f' </navPoint>'
|
||||
)
|
||||
|
||||
safe_title = he(title)
|
||||
safe_author = he(author)
|
||||
|
||||
ncx = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN"\n'
|
||||
' "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">\n'
|
||||
'<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">\n'
|
||||
"<head>\n"
|
||||
f' <meta name="dtb:uid" content="{book_id}"/>\n'
|
||||
' <meta name="dtb:depth" content="1"/>\n'
|
||||
' <meta name="dtb:totalPageCount" content="0"/>\n'
|
||||
' <meta name="dtb:maxPageNumber" content="0"/>\n'
|
||||
"</head>\n"
|
||||
f"<docTitle><text>{safe_title}</text></docTitle>\n"
|
||||
f"<docAuthor><text>{safe_author}</text></docAuthor>\n"
|
||||
"<navMap>\n"
|
||||
+ "\n".join(ncx_nav_points)
|
||||
+ "\n</navMap>\n</ncx>\n"
|
||||
)
|
||||
z.writestr("OEBPS/toc.ncx", ncx)
|
||||
|
||||
has_break = break_png_path.exists()
|
||||
opf = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<package xmlns="http://www.idpf.org/2007/opf" version="2.0"'
|
||||
f' unique-identifier="BookId">\n'
|
||||
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"'
|
||||
' xmlns:opf="http://www.idpf.org/2007/opf">\n'
|
||||
f' <dc:title>{safe_title}</dc:title>\n'
|
||||
f' <dc:creator opf:role="aut">{safe_author}</dc:creator>\n'
|
||||
f' <dc:publisher>{he(publisher or "")}</dc:publisher>\n'
|
||||
f' <dc:identifier id="BookId" opf:scheme="UUID">{book_id}</dc:identifier>\n'
|
||||
f' <dc:date>{now_str}</dc:date>\n'
|
||||
' <dc:language>nl</dc:language>\n'
|
||||
"</metadata>\n"
|
||||
"<manifest>\n"
|
||||
' <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>\n'
|
||||
' <item id="style" href="Styles/style.css" media-type="text/css"/>\n'
|
||||
+ (
|
||||
' <item id="break-img" href="Images/break.png" media-type="image/png"/>\n'
|
||||
if has_break else ""
|
||||
)
|
||||
+ "\n".join(manifest_items)
|
||||
+ "\n</manifest>\n"
|
||||
'<spine toc="ncx">\n'
|
||||
+ "\n".join(spine_idrefs)
|
||||
+ "\n</spine>\n"
|
||||
"</package>\n"
|
||||
)
|
||||
z.writestr("OEBPS/content.opf", opf)
|
||||
|
||||
return buf.getvalue()
|
||||
|
||||
@ -9,10 +9,15 @@ from migrations import run_migrations
|
||||
from routers.backup import start_backup_scheduler, stop_backup_scheduler
|
||||
from routers import (
|
||||
backup_router,
|
||||
builder_router,
|
||||
bulk_import_router,
|
||||
changelog_router,
|
||||
editor_router,
|
||||
following_router,
|
||||
grabber_router,
|
||||
library_router,
|
||||
reader_router,
|
||||
search_router,
|
||||
settings_router,
|
||||
)
|
||||
|
||||
@ -38,6 +43,11 @@ app.include_router(editor_router)
|
||||
app.include_router(grabber_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(backup_router)
|
||||
app.include_router(builder_router)
|
||||
app.include_router(bulk_import_router)
|
||||
app.include_router(following_router)
|
||||
app.include_router(changelog_router)
|
||||
app.include_router(search_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
259
containers/novela/migrate_paths.py
Normal file
259
containers/novela/migrate_paths.py
Normal 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()
|
||||
@ -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:
|
||||
# Match library list sorting and common filters.
|
||||
_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:
|
||||
migrate_create_library()
|
||||
migrate_create_book_tags()
|
||||
@ -238,3 +360,14 @@ def run_migrations() -> None:
|
||||
migrate_create_backup_log()
|
||||
migrate_create_perf_indexes()
|
||||
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()
|
||||
|
||||
256
containers/novela/recover_decock049.py
Normal file
256
containers/novela/recover_decock049.py
Normal 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()
|
||||
@ -9,6 +9,7 @@ jinja2==3.1.4
|
||||
Pillow==11.0.0
|
||||
pymupdf==1.24.0
|
||||
rarfile==4.2
|
||||
py7zr==0.22.0
|
||||
dropbox==12.0.2
|
||||
apscheduler==3.10.4
|
||||
cryptography==44.0.1
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
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.following import router as following_router
|
||||
from routers.grabber import router as grabber_router
|
||||
from routers.library import router as library_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
|
||||
|
||||
__all__ = [
|
||||
@ -12,4 +17,9 @@ __all__ = [
|
||||
"grabber_router",
|
||||
"backup_router",
|
||||
"settings_router",
|
||||
"builder_router",
|
||||
"bulk_import_router",
|
||||
"following_router",
|
||||
"changelog_router",
|
||||
"search_router",
|
||||
]
|
||||
|
||||
@ -7,14 +7,17 @@ import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import dropbox
|
||||
import httpx
|
||||
from dropbox.exceptions import ApiError, AuthError
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from db import get_db_conn
|
||||
from routers.common import scan_media, upsert_book
|
||||
from security import decrypt_value, encrypt_value, is_encrypted_value
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
@ -31,6 +34,7 @@ DEFAULT_SCHEDULE_INTERVAL_HOURS = 24
|
||||
|
||||
|
||||
BACKUP_TASKS: dict[int, asyncio.Task] = {}
|
||||
BACKUP_PROGRESS: dict[int, dict] = {} # log_id → {done, total, phase}
|
||||
SCHEDULER_TASK: asyncio.Task | None = None
|
||||
|
||||
|
||||
@ -95,6 +99,66 @@ def _load_dropbox_token() -> str:
|
||||
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:
|
||||
root = (value or "").strip() or DEFAULT_DROPBOX_ROOT
|
||||
if not root.startswith("/"):
|
||||
@ -325,14 +389,36 @@ def _save_dropbox_retention_count(retention_count: int) -> None:
|
||||
|
||||
|
||||
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()
|
||||
if not token:
|
||||
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:
|
||||
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()
|
||||
except AuthError as e:
|
||||
raise RuntimeError(f"Dropbox auth failed: {e}")
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@ -586,10 +672,17 @@ def _prune_orphan_objects(client: dropbox.Dropbox, objects_root: str, referenced
|
||||
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()
|
||||
manifest = _load_manifest()
|
||||
files = _iter_library_files()
|
||||
total_files = len(files)
|
||||
|
||||
_prog(0, total_files, "scanning")
|
||||
|
||||
uploaded_count = 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]] = {}
|
||||
|
||||
for path in files:
|
||||
for idx, path in enumerate(files):
|
||||
_prog(idx, total_files, "uploading")
|
||||
rel = path.relative_to(LIBRARY_DIR).as_posix()
|
||||
state = _current_file_state(path)
|
||||
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_count += 1
|
||||
|
||||
_prog(total_files, total_files, "snapshot")
|
||||
|
||||
snapshot = {
|
||||
"created_at": _now_iso(),
|
||||
"retention_count": retention_count,
|
||||
@ -661,6 +757,8 @@ def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]:
|
||||
uploaded_size += len(snapshot_data)
|
||||
uploaded_count += 1
|
||||
|
||||
_prog(total_files, total_files, "pg_dump")
|
||||
|
||||
dump_data, dump_name = _run_pg_dump()
|
||||
dump_target = _dropbox_join(dropbox_root, "postgres", dump_name)
|
||||
if client is not None:
|
||||
@ -691,10 +789,15 @@ async def backup_dropbox_credentials():
|
||||
preview = ""
|
||||
if token:
|
||||
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 {
|
||||
"configured": bool(token),
|
||||
"token_preview": preview,
|
||||
"updated_at": details.get("updated_at"),
|
||||
"app_key_configured": bool(app_key and app_secret),
|
||||
"dropbox_root": root_details.get("root", DEFAULT_DROPBOX_ROOT),
|
||||
"root_updated_at": root_details.get("updated_at"),
|
||||
"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:
|
||||
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())
|
||||
raw_retention = body.get("retention_count", _load_dropbox_retention_count())
|
||||
try:
|
||||
@ -748,6 +854,11 @@ async def backup_dropbox_credentials_save(request: Request):
|
||||
(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_retention_count(retention_count)
|
||||
_save_backup_schedule(schedule_enabled, schedule_interval_hours)
|
||||
@ -768,7 +879,14 @@ async def backup_dropbox_credentials_delete():
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
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}
|
||||
|
||||
@ -797,6 +915,8 @@ async def backup_health():
|
||||
"dropbox_error": dropbox_error,
|
||||
"dropbox_root": dropbox_root,
|
||||
"retention_count": retention_count,
|
||||
"schedule_enabled": schedule_enabled,
|
||||
"schedule_interval_hours": schedule_interval_hours,
|
||||
"pg_dump_available": bool(pg_dump_path),
|
||||
"pg_dump_path": pg_dump_path,
|
||||
"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:
|
||||
BACKUP_PROGRESS[log_id] = {"done": 0, "total": 0, "phase": "starting"}
|
||||
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(
|
||||
log_id,
|
||||
status="success",
|
||||
@ -936,6 +1059,115 @@ async def _run_backup_job(log_id: int, dry_run: bool) -> None:
|
||||
)
|
||||
finally:
|
||||
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")
|
||||
@ -965,3 +1197,131 @@ async def run_backup(request: Request):
|
||||
"message": "Backup started in background.",
|
||||
"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}
|
||||
|
||||
270
containers/novela/routers/builder.py
Normal file
270
containers/novela/routers/builder.py
Normal file
@ -0,0 +1,270 @@
|
||||
"""Book Builder — routes voor het handmatig aanmaken van EPUB-boeken."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from db import get_db_conn
|
||||
from epub import build_epub
|
||||
from routers.common import LIBRARY_DIR, make_rel_path, upsert_book
|
||||
from xhtml import normalize_wysiwyg_html
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_draft(conn, draft_id: str) -> dict | None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, title, author, publisher, source_url, chapters "
|
||||
"FROM builder_drafts WHERE id = %s",
|
||||
(draft_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": str(row[0]),
|
||||
"title": row[1],
|
||||
"author": row[2],
|
||||
"publisher": row[3],
|
||||
"source_url": row[4],
|
||||
"chapters": row[5] or [],
|
||||
}
|
||||
|
||||
|
||||
# ── Pagina-routes ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/builder", response_class=HTMLResponse)
|
||||
async def builder_index(request: Request):
|
||||
with get_db_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, title, author, updated_at "
|
||||
"FROM builder_drafts ORDER BY updated_at DESC"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
drafts = [
|
||||
{"id": str(r[0]), "title": r[1], "author": r[2], "updated_at": r[3]}
|
||||
for r in rows
|
||||
]
|
||||
return templates.TemplateResponse(
|
||||
request, "builder.html", {"view": "index", "drafts": drafts, "active": "builder"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/builder/{draft_id}", response_class=HTMLResponse)
|
||||
async def builder_editor(draft_id: str, request: Request):
|
||||
with get_db_conn() as conn:
|
||||
draft = _get_draft(conn, draft_id)
|
||||
if not draft:
|
||||
return HTMLResponse("Draft niet gevonden", status_code=404)
|
||||
return templates.TemplateResponse(
|
||||
request, "builder.html", {"view": "editor", "draft": draft, "active": "builder"}
|
||||
)
|
||||
|
||||
|
||||
# ── Draft aanmaken / verwijderen ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/builder")
|
||||
async def create_draft(request: Request):
|
||||
form = await request.form()
|
||||
title = (form.get("title") or "").strip()
|
||||
author = (form.get("author") or "").strip()
|
||||
publisher = (form.get("publisher") or "").strip()
|
||||
source_url = (form.get("source_url") or "").strip()
|
||||
|
||||
if not title or not author:
|
||||
return HTMLResponse("Titel en auteur zijn verplicht", status_code=400)
|
||||
|
||||
with get_db_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO builder_drafts (title, author, publisher, source_url, chapters) "
|
||||
"VALUES (%s, %s, %s, %s, '[]'::jsonb) RETURNING id",
|
||||
(title, author, publisher, source_url),
|
||||
)
|
||||
draft_id = str(cur.fetchone()[0])
|
||||
conn.commit()
|
||||
|
||||
return RedirectResponse(f"/builder/{draft_id}", status_code=303)
|
||||
|
||||
|
||||
@router.delete("/api/builder/{draft_id}")
|
||||
async def delete_draft(draft_id: str):
|
||||
with get_db_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM builder_drafts WHERE id = %s", (draft_id,))
|
||||
conn.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/builder/{draft_id}")
|
||||
async def get_draft_api(draft_id: str):
|
||||
with get_db_conn() as conn:
|
||||
draft = _get_draft(conn, draft_id)
|
||||
if not draft:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return JSONResponse(draft)
|
||||
|
||||
|
||||
# ── Chapter CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/builder/{draft_id}/chapter")
|
||||
async def add_chapter(draft_id: str, request: Request):
|
||||
body = await request.json()
|
||||
title = (body.get("title") or "Nieuw hoofdstuk").strip() or "Nieuw hoofdstuk"
|
||||
after_index = int(body.get("after_index", -1))
|
||||
|
||||
with get_db_conn() as conn:
|
||||
draft = _get_draft(conn, draft_id)
|
||||
if not draft:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
chapters = draft["chapters"]
|
||||
new_chapter = {"title": title, "content": "<p></p>"}
|
||||
insert_at = after_index + 1 if 0 <= after_index < len(chapters) else len(chapters)
|
||||
chapters.insert(insert_at, new_chapter)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE builder_drafts SET chapters = %s::jsonb, updated_at = NOW() "
|
||||
"WHERE id = %s",
|
||||
(json.dumps(chapters), draft_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return JSONResponse({"ok": True, "index": insert_at, "count": len(chapters)})
|
||||
|
||||
|
||||
@router.put("/api/builder/{draft_id}/chapter/{idx}")
|
||||
async def save_chapter(draft_id: str, idx: int, request: Request):
|
||||
body = await request.json()
|
||||
|
||||
with get_db_conn() as conn:
|
||||
draft = _get_draft(conn, draft_id)
|
||||
if not draft:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
chapters = draft["chapters"]
|
||||
if idx < 0 or idx >= len(chapters):
|
||||
return JSONResponse({"error": "chapter not found"}, status_code=404)
|
||||
|
||||
if "title" in body:
|
||||
chapters[idx]["title"] = body["title"]
|
||||
if "content" in body:
|
||||
chapters[idx]["content"] = body["content"]
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE builder_drafts SET chapters = %s::jsonb, updated_at = NOW() "
|
||||
"WHERE id = %s",
|
||||
(json.dumps(chapters), draft_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.delete("/api/builder/{draft_id}/chapter/{idx}")
|
||||
async def delete_chapter(draft_id: str, idx: int):
|
||||
with get_db_conn() as conn:
|
||||
draft = _get_draft(conn, draft_id)
|
||||
if not draft:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
chapters = draft["chapters"]
|
||||
if idx < 0 or idx >= len(chapters):
|
||||
return JSONResponse({"error": "chapter not found"}, status_code=404)
|
||||
if len(chapters) <= 1:
|
||||
return JSONResponse(
|
||||
{"error": "Kan het laatste hoofdstuk niet verwijderen"}, status_code=400
|
||||
)
|
||||
|
||||
chapters.pop(idx)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE builder_drafts SET chapters = %s::jsonb, updated_at = NOW() "
|
||||
"WHERE id = %s",
|
||||
(json.dumps(chapters), draft_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return JSONResponse({"ok": True, "index": min(idx, len(chapters) - 1), "count": len(chapters)})
|
||||
|
||||
|
||||
# ── Normaliseer preview ───────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/builder/{draft_id}/normalize/{idx}")
|
||||
async def normalize_chapter_preview(draft_id: str, idx: int, request: Request):
|
||||
"""Normaliseer één hoofdstuk en geef genormaliseerde HTML terug als preview.
|
||||
Slaat het resultaat NIET op — de frontend vraagt bevestiging.
|
||||
"""
|
||||
body = await request.json()
|
||||
raw_html = body.get("content", "")
|
||||
normalized = normalize_wysiwyg_html(raw_html)
|
||||
return JSONResponse({"ok": True, "content": normalized})
|
||||
|
||||
|
||||
# ── Publiceer als EPUB ────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/builder/{draft_id}/publish")
|
||||
async def publish_draft(draft_id: str):
|
||||
with get_db_conn() as conn:
|
||||
draft = _get_draft(conn, draft_id)
|
||||
if not draft:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if not draft["chapters"]:
|
||||
return JSONResponse({"error": "Geen hoofdstukken om te publiceren"}, status_code=400)
|
||||
|
||||
# Normaliseer alle hoofdstukken
|
||||
normalized_chapters = [
|
||||
{"title": ch["title"], "content": normalize_wysiwyg_html(ch["content"])}
|
||||
for ch in draft["chapters"]
|
||||
]
|
||||
|
||||
# Bouw EPUB
|
||||
epub_bytes = build_epub(
|
||||
title=draft["title"],
|
||||
author=draft["author"],
|
||||
publisher=draft["publisher"],
|
||||
chapters=normalized_chapters,
|
||||
)
|
||||
|
||||
# Bepaal bestandspad
|
||||
rel_path = make_rel_path(
|
||||
media_type="epub",
|
||||
publisher=draft["publisher"] or "Unknown",
|
||||
author=draft["author"],
|
||||
title=draft["title"],
|
||||
series="",
|
||||
series_index=0,
|
||||
ext=".epub",
|
||||
)
|
||||
dest = LIBRARY_DIR / rel_path
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(epub_bytes)
|
||||
|
||||
filename = str(rel_path)
|
||||
meta = {
|
||||
"media_type": "epub",
|
||||
"title": draft["title"],
|
||||
"author": draft["author"],
|
||||
"publisher": draft["publisher"],
|
||||
"source_url": draft["source_url"],
|
||||
"needs_review": True,
|
||||
"has_cover": False,
|
||||
}
|
||||
upsert_book(conn, filename, meta)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM builder_drafts WHERE id = %s", (draft_id,))
|
||||
conn.commit()
|
||||
|
||||
return JSONResponse({"ok": True, "filename": filename})
|
||||
145
containers/novela/routers/bulk_import.py
Normal file
145
containers/novela/routers/bulk_import.py
Normal 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}
|
||||
17
containers/novela/routers/changelog.py
Normal file
17
containers/novela/routers/changelog.py
Normal 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,
|
||||
})
|
||||
@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import html as _html
|
||||
import io
|
||||
import posixpath
|
||||
@ -18,10 +19,16 @@ from pdf import pdf_cover_thumb, pdf_page_count, pdf_scan_metadata
|
||||
LIBRARY_DIR = Path("library")
|
||||
LIBRARY_DIR.mkdir(exist_ok=True)
|
||||
LIBRARY_ROOT = LIBRARY_DIR.resolve()
|
||||
IMAGES_DIR = LIBRARY_DIR / "images"
|
||||
COVER_W = 300
|
||||
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:
|
||||
txt = re.sub(r"\s+", " ", (value or "").strip())
|
||||
txt = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", txt)
|
||||
@ -52,31 +59,71 @@ def media_type_from_suffix(path: Path) -> str:
|
||||
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 0–999; 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:
|
||||
try:
|
||||
return max(1, min(999, int(value or 1)))
|
||||
return max(0, min(999, int(value or 0)))
|
||||
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":
|
||||
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:
|
||||
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"
|
||||
|
||||
if media_type == "pdf":
|
||||
pub = clean_segment(publisher, "Unknown Publisher", 80)
|
||||
auth = clean_segment(author, "Unknown Author", 80)
|
||||
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)
|
||||
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:
|
||||
@ -191,6 +238,7 @@ def scan_epub(path: Path) -> dict:
|
||||
"has_cover": False,
|
||||
"series": "",
|
||||
"series_index": 0,
|
||||
"series_suffix": "",
|
||||
"title": "",
|
||||
"publication_status": "",
|
||||
"author": "",
|
||||
@ -229,6 +277,9 @@ def scan_epub(path: Path) -> dict:
|
||||
out["series_index"] = int(float(m.group(1)))
|
||||
except Exception:
|
||||
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)
|
||||
if m:
|
||||
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)
|
||||
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:
|
||||
pass
|
||||
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:
|
||||
mt = media_type_from_suffix(path)
|
||||
if mt == "epub":
|
||||
@ -271,6 +345,8 @@ def scan_media(path: Path) -> dict:
|
||||
"publish_date": "",
|
||||
"subjects": [],
|
||||
}
|
||||
if path.suffix.lower() == ".cbz":
|
||||
meta["rating"] = scan_cbz_rating(path)
|
||||
else:
|
||||
meta = {}
|
||||
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:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO library (filename, media_type, title, author, publisher, has_cover,
|
||||
series, series_index, publication_status, source_url,
|
||||
publish_date, description, needs_review, want_to_read, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, NOW())
|
||||
INSERT INTO library (filename, media_type, storage_type, title, author, publisher, has_cover,
|
||||
series, series_index, series_suffix, publication_status, source_url,
|
||||
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, %s, %s, FALSE, %s, NOW())
|
||||
ON CONFLICT (filename) DO UPDATE SET
|
||||
media_type = EXCLUDED.media_type,
|
||||
storage_type = EXCLUDED.storage_type,
|
||||
title = COALESCE(NULLIF(EXCLUDED.title, ''), library.title),
|
||||
author = COALESCE(NULLIF(EXCLUDED.author, ''), library.author),
|
||||
publisher = COALESCE(NULLIF(EXCLUDED.publisher, ''), library.publisher),
|
||||
has_cover = (library.has_cover OR EXCLUDED.has_cover),
|
||||
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_suffix = COALESCE(NULLIF(EXCLUDED.series_suffix, ''), library.series_suffix),
|
||||
publication_status = COALESCE(NULLIF(EXCLUDED.publication_status, ''), library.publication_status),
|
||||
source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), library.source_url),
|
||||
publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date),
|
||||
description = COALESCE(NULLIF(EXCLUDED.description, ''), library.description),
|
||||
rating = CASE WHEN EXCLUDED.rating > 0 THEN EXCLUDED.rating ELSE library.rating END,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
filename,
|
||||
meta.get("media_type", "epub"),
|
||||
meta.get("storage_type", "file"),
|
||||
meta.get("title", ""),
|
||||
meta.get("author", ""),
|
||||
meta.get("publisher", ""),
|
||||
bool(meta.get("has_cover", False)),
|
||||
meta.get("series", ""),
|
||||
meta.get("series_index", 0),
|
||||
meta.get("series_suffix", ""),
|
||||
meta.get("publication_status", ""),
|
||||
meta.get("source_url", ""),
|
||||
meta.get("publish_date") or None,
|
||||
meta.get("description", ""),
|
||||
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,
|
||||
COALESCE(rs.read_count, 0)::int AS read_count,
|
||||
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
|
||||
LEFT JOIN reading_progress rp ON rp.filename = l.filename
|
||||
LEFT JOIN (
|
||||
@ -357,16 +445,17 @@ def list_library_json() -> list[dict]:
|
||||
GROUP BY filename
|
||||
) rs ON rs.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, '')
|
||||
"""
|
||||
)
|
||||
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 = []
|
||||
for r in rows:
|
||||
@ -381,6 +470,7 @@ def list_library_json() -> list[dict]:
|
||||
"has_cached_cover": bool(r[18]),
|
||||
"series": r[6] or "",
|
||||
"series_index": r[7] or 0,
|
||||
"series_suffix": r[20] or "",
|
||||
"publication_status": r[8] or "",
|
||||
"want_to_read": bool(r[9]),
|
||||
"archived": bool(r[10]),
|
||||
@ -391,28 +481,90 @@ def list_library_json() -> list[dict]:
|
||||
"page": r[15],
|
||||
"read_count": r[16] or 0,
|
||||
"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
|
||||
|
||||
|
||||
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:
|
||||
if has_cover:
|
||||
cur.execute(
|
||||
"DELETE FROM book_tags WHERE filename = %s AND tag = 'Cover Missing' AND tag_type = 'tag'",
|
||||
(filename,),
|
||||
)
|
||||
return
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO book_tags (filename, tag, tag_type)
|
||||
VALUES (%s, 'Cover Missing', 'tag')
|
||||
ON CONFLICT (filename, tag, tag_type) DO NOTHING
|
||||
INSERT INTO book_images (sha256, ext, media_type, size_bytes)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
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:
|
||||
|
||||
@ -12,7 +12,7 @@ from fastapi.templating import Jinja2Templates
|
||||
|
||||
from db import get_db_conn
|
||||
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()
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
@ -158,6 +158,7 @@ def _rewrite_epub_entries(epub_path: Path, updates: dict[str, bytes], remove_pat
|
||||
|
||||
@router.get("/library/editor/{filename:path}", response_class=HTMLResponse)
|
||||
async def editor_page(filename: str, request: Request):
|
||||
if not is_db_filename(filename):
|
||||
path = resolve_library_path(filename)
|
||||
if path is None or not path.exists():
|
||||
return HTMLResponse("Not found", status_code=404)
|
||||
@ -166,13 +167,31 @@ async def editor_page(filename: str, request: Request):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT title FROM library WHERE filename = %s", (filename,))
|
||||
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}")
|
||||
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)
|
||||
if path is None or not path.exists():
|
||||
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}")
|
||||
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)
|
||||
if path is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if not path.exists():
|
||||
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||
body = await request.json()
|
||||
content = body.get("content", "")
|
||||
if not content:
|
||||
return JSONResponse({"error": "No content"}, status_code=400)
|
||||
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}")
|
||||
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)
|
||||
if path is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if not path.exists():
|
||||
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||
|
||||
body = await request.json()
|
||||
title = (body.get("title") or "New chapter").strip() or "New chapter"
|
||||
after_index = body.get("after_index", -1)
|
||||
try:
|
||||
after_index = int(after_index)
|
||||
except Exception:
|
||||
after_index = -1
|
||||
try:
|
||||
after_index = int(after_index)
|
||||
except Exception:
|
||||
@ -339,6 +401,26 @@ async def add_edit_chapter(filename: str, request: Request):
|
||||
|
||||
@router.delete("/api/edit/chapter/{index:int}/{filename:path}")
|
||||
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)
|
||||
if path is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
68
containers/novela/routers/following.py
Normal file
68
containers/novela/routers/following.py
Normal 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}
|
||||
@ -17,12 +17,16 @@ from db import get_db_conn
|
||||
from epub import detect_image_format, make_chapter_xhtml, make_epub
|
||||
from routers.common import (
|
||||
LIBRARY_DIR,
|
||||
ensure_cover_cache_for_book,
|
||||
ensure_cover_missing_tag,
|
||||
ensure_unique_db_filename,
|
||||
ensure_unique_rel_path,
|
||||
make_cover_thumb_webp,
|
||||
make_rel_path,
|
||||
normalize_site,
|
||||
store_db_image,
|
||||
upsert_book,
|
||||
upsert_chapter,
|
||||
upsert_cover_cache,
|
||||
write_image_file,
|
||||
)
|
||||
from scrapers import get_scraper
|
||||
from scrapers.base import HEADERS
|
||||
@ -136,22 +140,87 @@ async def debug_run(request: Request):
|
||||
result: dict = {}
|
||||
try:
|
||||
async with httpx.AsyncClient(headers=HEADERS, follow_redirects=True, timeout=30) as client:
|
||||
# Login
|
||||
login_success = False
|
||||
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)
|
||||
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", ""),
|
||||
"author": book.get("author", ""),
|
||||
"publisher": book.get("publisher", ""),
|
||||
"series": book.get("series", ""),
|
||||
"chapter_count": len(book.get("chapters", [])),
|
||||
"chapter_method": book.get("chapter_method", ""),
|
||||
"genres": book.get("genres", []),
|
||||
"subgenres": book.get("subgenres", []),
|
||||
"tags": book.get("tags", []),
|
||||
"description": book.get("description", ""),
|
||||
"updated_date": book.get("updated_date", ""),
|
||||
"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:
|
||||
result["error"] = traceback.format_exc()
|
||||
return result
|
||||
@ -217,9 +286,37 @@ async def preload(request: Request):
|
||||
book = await scraper.fetch_book_info(client, url)
|
||||
series = book.get("series", "")
|
||||
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 {
|
||||
"title": book.get("title", ""),
|
||||
"author": book.get("author", ""),
|
||||
"title": title,
|
||||
"author": author,
|
||||
"publisher": book.get("publisher", ""),
|
||||
"series": 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", ""),
|
||||
"updated_date": book.get("updated_date", ""),
|
||||
"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", []))
|
||||
if len(book["chapters"]) < 4 and "Shorts" not in tags:
|
||||
tags.append("Shorts")
|
||||
if cover_data is None and "Cover Missing" not in tags:
|
||||
tags.append("Cover Missing")
|
||||
|
||||
status_map = {"Long-Term Hold": "Hiatus"}
|
||||
status_map = {"Temporary-Hold": "Temporary Hold"}
|
||||
pub_status = status_map.get(book.get("publication_status", ""), book.get("publication_status", ""))
|
||||
|
||||
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()
|
||||
break_img_data = open("static/break.png", "rb").read()
|
||||
|
||||
# Collect chapters as {title, content_html, images: [(sha256, ext, media_type, size, data)]}
|
||||
chapters = []
|
||||
for i, ch in enumerate(book["chapters"], 1):
|
||||
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)
|
||||
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:
|
||||
img_counter = 1
|
||||
for img_tag in content_el.find_all("img"):
|
||||
if is_break_element(img_tag):
|
||||
img_tag.decompose()
|
||||
continue
|
||||
src = img_tag.get("src", "")
|
||||
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:
|
||||
img_resp = await client.get(urljoin(ch["url"], src))
|
||||
if img_resp.status_code == 200:
|
||||
img_name, img_mime = detect_image_format(
|
||||
img_resp.content, f"ch{i:03d}_img{img_counter:03d}"
|
||||
_, img_mime = detect_image_format(
|
||||
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", "")
|
||||
chapter_images.append(
|
||||
{
|
||||
"epub_path": f"OEBPS/Images/{img_name}",
|
||||
"data": img_resp.content,
|
||||
"media_type": img_mime,
|
||||
img_tag.attrs = {
|
||||
k: v for k, v in img_tag.attrs.items()
|
||||
if k in ("src", "alt", "width", "height")
|
||||
}
|
||||
)
|
||||
img_counter += 1
|
||||
else:
|
||||
img_tag.decompose()
|
||||
except Exception:
|
||||
@ -360,9 +453,8 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
|
||||
if part.strip():
|
||||
xhtml_parts.append(part)
|
||||
|
||||
content_xhtml = "\n".join(xhtml_parts)
|
||||
chapter_xhtml = make_chapter_xhtml(ch_data["title"], content_xhtml, i)
|
||||
chapters.append({"title": ch_data["title"], "xhtml": chapter_xhtml, "images": chapter_images})
|
||||
content_html = "\n".join(xhtml_parts)
|
||||
chapters.append({"title": ch_data["title"], "content_html": content_html})
|
||||
await asyncio.sleep(0.2)
|
||||
except Exception as 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
|
||||
return
|
||||
|
||||
send("status", {"message": "Building EPUB..."})
|
||||
book_id = str(uuid.uuid4())
|
||||
epub_bytes = make_epub(book_title, author, chapters, cover_data, break_img_data, book_id, book_info)
|
||||
storage_mode = job.get("storage_mode", "db")
|
||||
send("status", {"message": "Saving to library..."})
|
||||
|
||||
rel = ensure_unique_rel_path(
|
||||
make_rel_path(
|
||||
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", [])]
|
||||
)
|
||||
|
||||
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",
|
||||
publisher=book_info.get("publisher", ""),
|
||||
author=author,
|
||||
@ -385,16 +495,51 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
|
||||
series=series,
|
||||
series_index=series_index,
|
||||
)
|
||||
)
|
||||
out_path = LIBRARY_DIR / rel
|
||||
rel_path = ensure_unique_rel_path(rel_path)
|
||||
out_path = LIBRARY_DIR / rel_path
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(epub_bytes)
|
||||
|
||||
rel_filename = rel.as_posix()
|
||||
job["filename"] = rel_filename
|
||||
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,
|
||||
@ -407,19 +552,21 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
|
||||
"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:
|
||||
rel_filename = ensure_unique_db_filename(conn, base_filename)
|
||||
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")
|
||||
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
|
||||
|
||||
send("done", {"filename": rel_filename, "title": book_title, "chapters": len(chapters)})
|
||||
job["filename"] = rel_filename
|
||||
send("done", {"filename": rel_filename, "title": book_title, "chapters": len(chapters), "storage_type": storage_mode})
|
||||
job["done"] = True
|
||||
|
||||
|
||||
@ -446,6 +593,7 @@ async def convert(request: Request):
|
||||
|
||||
job["series_index"] = int(body.get("series_index", 1) or 1)
|
||||
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
|
||||
asyncio.create_task(scrape_book(job_id, url, username, password))
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import base64
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
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 PIL import UnidentifiedImageError
|
||||
|
||||
@ -13,8 +14,8 @@ from epub import add_cover_to_epub
|
||||
from routers.common import (
|
||||
LIBRARY_DIR,
|
||||
ensure_cover_cache_for_book,
|
||||
ensure_cover_missing_tag,
|
||||
ensure_unique_rel_path,
|
||||
is_db_filename,
|
||||
list_library_json,
|
||||
make_cover_thumb_webp,
|
||||
make_rel_path,
|
||||
@ -50,7 +51,6 @@ def _sync_disk_to_db() -> int:
|
||||
continue
|
||||
tags = [(s, "subject") for s in meta.get("subjects", [])]
|
||||
upsert_book(conn, rel, meta, tags)
|
||||
ensure_cover_missing_tag(conn, rel, bool(meta.get("has_cover")))
|
||||
if bool(meta.get("has_cover")):
|
||||
ensure_cover_cache_for_book(conn, rel, p, meta["media_type"])
|
||||
synced += 1
|
||||
@ -71,19 +71,33 @@ async def library_page(request: Request):
|
||||
|
||||
|
||||
@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.
|
||||
# Use /library/rescan (or ?rescan=true) when a full sync is needed.
|
||||
if rescan:
|
||||
_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()
|
||||
if include_file_info:
|
||||
for b in books:
|
||||
p = resolve_library_path(b["filename"])
|
||||
if p and p.exists():
|
||||
b.update(relative_file_info(p))
|
||||
return books
|
||||
return JSONResponse(content=books, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
@router.post("/library/rescan")
|
||||
@ -130,6 +144,8 @@ async def library_import(files: list[UploadFile] = File(...)):
|
||||
title=meta.get("title") or Path(name).stem,
|
||||
series=meta.get("series", ""),
|
||||
series_index=meta.get("series_index", 0),
|
||||
series_suffix=meta.get("series_suffix", ""),
|
||||
ext=suffix,
|
||||
)
|
||||
)
|
||||
dest = LIBRARY_DIR / rel
|
||||
@ -140,7 +156,6 @@ async def library_import(files: list[UploadFile] = File(...)):
|
||||
meta["needs_review"] = True
|
||||
tags = [(s, "subject") for s in meta.get("subjects", [])]
|
||||
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)
|
||||
imported.append(rel_name)
|
||||
except Exception as e:
|
||||
@ -151,8 +166,27 @@ async def library_import(files: list[UploadFile] = File(...)):
|
||||
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}")
|
||||
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)
|
||||
if full is None:
|
||||
return {"error": "Invalid filename"}
|
||||
@ -170,8 +204,48 @@ async def library_delete(filename: str):
|
||||
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}")
|
||||
async def library_cover_cached(filename: str):
|
||||
if not is_db_filename(filename):
|
||||
full = resolve_library_path(filename)
|
||||
if full is None or not full.exists():
|
||||
return Response(status_code=404)
|
||||
@ -205,6 +279,19 @@ async def library_cover_cached(filename: str):
|
||||
|
||||
@router.get("/library/cover/{filename:path}")
|
||||
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)
|
||||
if full is None or not full.exists():
|
||||
return Response(status_code=404)
|
||||
@ -213,10 +300,30 @@ async def library_cover(filename: str):
|
||||
if mt == "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)
|
||||
if not extracted:
|
||||
return Response(status_code=404)
|
||||
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)
|
||||
|
||||
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)
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
pass
|
||||
ensure_cover_missing_tag(conn, filename, True)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@ -368,7 +474,7 @@ async def api_home():
|
||||
l.series, l.series_index, l.publication_status,
|
||||
l.media_type,
|
||||
COALESCE(rp.progress, 0) AS progress,
|
||||
rp.cfi
|
||||
rp.cfi, l.rating
|
||||
FROM reading_progress rp
|
||||
JOIN library l ON l.filename = rp.filename
|
||||
WHERE rp.progress > 0
|
||||
@ -380,7 +486,7 @@ async def api_home():
|
||||
|
||||
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
|
||||
LEFT JOIN reading_sessions rs ON rs.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_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()
|
||||
"""
|
||||
)
|
||||
@ -404,7 +510,7 @@ async def api_home():
|
||||
|
||||
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
|
||||
LEFT JOIN reading_sessions rs ON rs.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_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()
|
||||
"""
|
||||
)
|
||||
@ -429,7 +535,7 @@ async def api_home():
|
||||
cur.execute(
|
||||
"""
|
||||
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
|
||||
JOIN reading_sessions rs ON rs.filename = l.filename
|
||||
WHERE COALESCE(l.series, '') = ''
|
||||
@ -442,7 +548,7 @@ async def api_home():
|
||||
AND bt.tag = 'Shorts'
|
||||
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
|
||||
"""
|
||||
)
|
||||
@ -451,7 +557,7 @@ async def api_home():
|
||||
cur.execute(
|
||||
"""
|
||||
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
|
||||
JOIN reading_sessions rs ON rs.filename = l.filename
|
||||
WHERE COALESCE(l.series, '') = ''
|
||||
@ -464,7 +570,7 @@ async def api_home():
|
||||
AND bt.tag = 'Shorts'
|
||||
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
|
||||
"""
|
||||
)
|
||||
@ -479,6 +585,7 @@ async def api_home():
|
||||
"has_cover": bool(r[3]),
|
||||
"publication_status": r[4] or "",
|
||||
"media_type": r[5] or "epub",
|
||||
"rating": r[6] or 0,
|
||||
"progress": 0,
|
||||
"series": "",
|
||||
"series_index": 0,
|
||||
@ -496,6 +603,7 @@ async def api_home():
|
||||
"publication_status": r[4] or "",
|
||||
"media_type": r[5] or "epub",
|
||||
"last_read": r[6].isoformat() if r[6] else None,
|
||||
"rating": r[7] or 0,
|
||||
"progress": 0,
|
||||
"series": "",
|
||||
"series_index": 0,
|
||||
@ -516,6 +624,7 @@ async def api_home():
|
||||
"media_type": r[7] or "epub",
|
||||
"progress": r[8] or 0,
|
||||
"progress_cfi": r[9],
|
||||
"rating": r[10] or 0,
|
||||
}
|
||||
for r in cr_rows
|
||||
],
|
||||
@ -531,6 +640,73 @@ async def stats_page(request: Request):
|
||||
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")
|
||||
async def api_stats():
|
||||
with get_db_conn() as conn:
|
||||
@ -691,5 +867,5 @@ async def api_stats():
|
||||
|
||||
|
||||
@router.get("/library/list")
|
||||
async def library_list_compat():
|
||||
return await api_library()
|
||||
async def library_list_compat(request: Request):
|
||||
return await api_library(request)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
63
containers/novela/routers/search.py
Normal file
63
containers/novela/routers/search.py
Normal 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)
|
||||
@ -1,9 +1,11 @@
|
||||
from .base import BaseScraper
|
||||
from .archiveofourown import ArchiveOfOurOwnScraper
|
||||
from .awesomedude import AwesomeDudeScraper
|
||||
from .gayauthors import GayAuthorsScraper
|
||||
|
||||
# Register scrapers in priority order (first match wins)
|
||||
_SCRAPERS: list[type[BaseScraper]] = [
|
||||
ArchiveOfOurOwnScraper,
|
||||
AwesomeDudeScraper,
|
||||
GayAuthorsScraper,
|
||||
]
|
||||
|
||||
206
containers/novela/scrapers/archiveofourown.py
Normal file
206
containers/novela/scrapers/archiveofourown.py
Normal 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,
|
||||
}
|
||||
BIN
containers/novela/static/apple-touch-icon.png
Normal file
BIN
containers/novela/static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
@ -1,13 +1,5 @@
|
||||
/* ── 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; }
|
||||
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 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 */
|
||||
.btn-wtr {
|
||||
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;
|
||||
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-ongoing { background: rgba(200,160,58,0.12); color: var(--warning); border: 1px solid rgba(200,160,58,0.25); }
|
||||
.status-hiatus { background: rgba(200,160,58,0.12); color: var(--warning); border: 1px solid rgba(200,160,58,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(74,144,184,0.12); color: #4a90b8; border: 1px solid rgba(74,144,184,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-section { margin-bottom: 1.25rem; }
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* ── 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;
|
||||
|
||||
@ -8,43 +8,6 @@ const { filename, title, author } = BOOK;
|
||||
const canvas = document.getElementById('cover-canvas');
|
||||
canvas.width = 180;
|
||||
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));
|
||||
if (BOOK.has_cover) {
|
||||
const img = document.getElementById('cover-img');
|
||||
@ -52,6 +15,27 @@ if (BOOK.has_cover) {
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
async function toggleWtr() {
|
||||
@ -187,6 +171,11 @@ class PillInput {
|
||||
this._hideDropdown();
|
||||
}
|
||||
|
||||
flush() {
|
||||
const v = this.input.value.trim();
|
||||
if (v) this._add(v);
|
||||
}
|
||||
|
||||
_showDropdown(items) {
|
||||
if (!items.length) { this.dropdown.style.display = 'none'; return; }
|
||||
this.dropdown.innerHTML = items.map(g =>
|
||||
@ -221,7 +210,7 @@ class PillInput {
|
||||
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') {
|
||||
} else if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
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);
|
||||
@ -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 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,'"')}">${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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
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=subgenre').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);
|
||||
subgenreInput.setSuggestions(allSubgenres);
|
||||
tagInput.setSuggestions(allTags);
|
||||
authorSuggest.setSuggestions(allAuthors);
|
||||
publisherSuggest.setSuggestions(allPublishers);
|
||||
seriesSuggest.setSuggestions(allSeries);
|
||||
|
||||
document.getElementById('ed-title').value = BOOK.title;
|
||||
document.getElementById('ed-author').value = BOOK.author;
|
||||
document.getElementById('ed-publisher').value = BOOK.publisher;
|
||||
document.getElementById('ed-series').value = BOOK.series;
|
||||
document.getElementById('ed-series-index').value = BOOK.series_index;
|
||||
document.getElementById('ed-status').value = BOOK.publication_status;
|
||||
document.getElementById('ed-series-index').value = BOOK.series_index + (BOOK.series_suffix || '');
|
||||
document.getElementById('ed-status').value = BOOK.publication_status || 'Complete';
|
||||
document.getElementById('ed-url').value = BOOK.source_url;
|
||||
document.getElementById('ed-publish-date').value = BOOK.publish_date;
|
||||
document.getElementById('ed-description').value = BOOK.description;
|
||||
@ -274,6 +328,9 @@ function closeEdit() {
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
genreInput.flush();
|
||||
subgenreInput.flush();
|
||||
tagInput.flush();
|
||||
const body = {
|
||||
title: document.getElementById('ed-title').value,
|
||||
author: document.getElementById('ed-author').value,
|
||||
|
||||
127
containers/novela/static/books.js
Normal file
127
containers/novela/static/books.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 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()); }
|
||||
});
|
||||
}
|
||||
296
containers/novela/static/builder.css
Normal file
296
containers/novela/static/builder.css
Normal file
@ -0,0 +1,296 @@
|
||||
/* ── Wrapper ── */
|
||||
.builder-wrap {
|
||||
margin-left: var(--sidebar, 220px);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Overzichtspagina ── */
|
||||
.builder-index {
|
||||
padding: 2rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
.builder-index-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.builder-create-card {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1.2rem 1.4rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.builder-create-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.builder-create-form { display: flex; flex-direction: column; gap: 0.7rem; }
|
||||
.bc-row { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.bc-label { font-size: 0.78rem; color: var(--text-dim); }
|
||||
.bc-required { color: #e07070; }
|
||||
.bc-input {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.88rem;
|
||||
outline: none;
|
||||
}
|
||||
.bc-input:focus { border-color: var(--accent); }
|
||||
.bc-actions { margin-top: 0.4rem; }
|
||||
|
||||
/* Drafts lijst */
|
||||
.builder-drafts-label {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.builder-drafts-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.draft-card {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.8rem;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
}
|
||||
.draft-card-title {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
}
|
||||
.draft-card-title:hover { text-decoration: underline; }
|
||||
.draft-card-meta { font-size: 0.76rem; color: var(--text-dim); }
|
||||
.draft-card-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
.draft-card-delete:hover { color: #e07070; }
|
||||
|
||||
/* ── Editor layout ── */
|
||||
.builder-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.builder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.builder-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
}
|
||||
.builder-back:hover { color: var(--text); }
|
||||
.builder-header-title {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.builder-header-actions { display: flex; align-items: center; gap: 0.7rem; }
|
||||
|
||||
/* Toolbar */
|
||||
.builder-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.3rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
}
|
||||
.tb-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.tb-btn:hover {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.tb-sep {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
margin: 0 0.3rem;
|
||||
}
|
||||
.tb-normalize {
|
||||
margin-left: auto;
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
border-color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.builder-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Chapter panel */
|
||||
.builder-chapter-panel {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.chapter-panel-title {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
padding: 0.6rem 0.9rem 0.3rem;
|
||||
}
|
||||
.chapter-list { flex: 1; overflow-y: auto; }
|
||||
.chapter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.9rem;
|
||||
cursor: pointer;
|
||||
border-left: 2px solid transparent;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.chapter-item:hover { background: var(--surface); }
|
||||
.chapter-item.active {
|
||||
border-left-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
.chapter-item-title {
|
||||
flex: 1;
|
||||
font-size: 0.83rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chapter-item-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
}
|
||||
.chapter-item:hover .chapter-item-delete { color: var(--text-dim); }
|
||||
.chapter-item-delete:hover { color: #e07070 !important; }
|
||||
.btn-add-chapter {
|
||||
background: none;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.btn-add-chapter:hover { color: var(--text); }
|
||||
|
||||
/* Editor pane */
|
||||
.builder-editor-pane {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem 3rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
.builder-content {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
min-height: 60vh;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.75;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
.builder-content p { margin: 0 0 0.8em; }
|
||||
.builder-content blockquote {
|
||||
border-left: 3px solid var(--border);
|
||||
margin: 1em 2em;
|
||||
padding: 0.3em 0.8em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.builder-content blockquote.author-note {
|
||||
font-style: italic;
|
||||
color: var(--text-dim);
|
||||
border-left-color: #555;
|
||||
font-size: 0.93em;
|
||||
}
|
||||
.builder-content img.scene-break {
|
||||
display: block;
|
||||
margin: 1em auto;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.save-status { font-size: 0.78rem; color: var(--text-dim); }
|
||||
.save-status.dirty { color: var(--warning, #c8a03a); }
|
||||
.save-status.saved { color: var(--success, #6baa6b); }
|
||||
.save-status.error { color: var(--error, #c85a3a); }
|
||||
|
||||
/* Knoppen */
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.83rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.85; }
|
||||
.btn-publish {
|
||||
background: #4a7a4a;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.83rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
}
|
||||
.btn-publish:hover { background: #5a8a5a; }
|
||||
.btn-publish:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
298
containers/novela/static/builder.js
Normal file
298
containers/novela/static/builder.js
Normal file
@ -0,0 +1,298 @@
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { draftId } = BUILDER;
|
||||
let chapters = BUILDER.chapters ? BUILDER.chapters.slice() : [];
|
||||
let currentIdx = -1;
|
||||
let isDirty = false;
|
||||
|
||||
const AUTOSAVE_MS = 30_000;
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof BUILDER === 'undefined') {
|
||||
// Overzichtspagina — bind verwijder-knoppen drafts
|
||||
bindDraftDeleteButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
if (chapters.length === 0) {
|
||||
apiAddChapter('Hoofdstuk 1', -1);
|
||||
} else {
|
||||
renderChapterList();
|
||||
loadChapter(0);
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
if (isDirty && currentIdx >= 0) saveCurrentChapter({ silent: true });
|
||||
}, AUTOSAVE_MS);
|
||||
|
||||
bindToolbar();
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
saveCurrentChapter();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function bindToolbar() {
|
||||
document.querySelectorAll('.tb-btn[data-cmd]').forEach(btn => {
|
||||
btn.addEventListener('mousedown', e => {
|
||||
e.preventDefault();
|
||||
document.execCommand(btn.dataset.cmd, false, null);
|
||||
getEditor().focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getEditor() {
|
||||
return document.getElementById('builder-content');
|
||||
}
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function markDirty() {
|
||||
isDirty = true;
|
||||
setStatus('dirty', 'Niet-opgeslagen wijzigingen');
|
||||
}
|
||||
|
||||
function setStatus(type, msg) {
|
||||
const el = document.getElementById('save-status');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = 'save-status' + (type ? ' ' + type : '');
|
||||
}
|
||||
|
||||
// ── Chapter list ──────────────────────────────────────────────────────────────
|
||||
|
||||
function renderChapterList() {
|
||||
const el = document.getElementById('chapter-list');
|
||||
el.innerHTML = '';
|
||||
chapters.forEach((ch, i) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'chapter-item' + (i === currentIdx ? ' active' : '');
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'chapter-item-title';
|
||||
label.textContent = ch.title || `Hoofdstuk ${i + 1}`;
|
||||
label.onclick = () => switchChapter(i);
|
||||
|
||||
const del = document.createElement('button');
|
||||
del.className = 'chapter-item-delete';
|
||||
del.title = 'Hoofdstuk verwijderen';
|
||||
del.textContent = '×';
|
||||
del.onclick = e => { e.stopPropagation(); deleteChapter(i); };
|
||||
|
||||
item.appendChild(label);
|
||||
item.appendChild(del);
|
||||
el.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load / switch ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function switchChapter(idx) {
|
||||
if (idx === currentIdx) return;
|
||||
if (isDirty) await saveCurrentChapter({ silent: true });
|
||||
loadChapter(idx);
|
||||
}
|
||||
|
||||
function loadChapter(idx) {
|
||||
currentIdx = idx;
|
||||
isDirty = false;
|
||||
const ch = chapters[idx];
|
||||
const editor = getEditor();
|
||||
editor.innerHTML = ch.content || '<p><br></p>';
|
||||
editor.oninput = markDirty;
|
||||
renderChapterList();
|
||||
setStatus('', '');
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function saveCurrentChapter({ silent = false } = {}) {
|
||||
if (currentIdx < 0) return;
|
||||
const content = getEditor().innerHTML;
|
||||
const title = chapters[currentIdx].title;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/builder/${draftId}/chapter/${currentIdx}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, content }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
chapters[currentIdx].content = content;
|
||||
isDirty = false;
|
||||
if (!silent) {
|
||||
const now = new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
|
||||
setStatus('saved', `Opgeslagen om ${now}`);
|
||||
}
|
||||
} else {
|
||||
setStatus('error', 'Opslaan mislukt');
|
||||
}
|
||||
} catch {
|
||||
setStatus('error', 'Netwerkfout bij opslaan');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add chapter ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function addChapter() {
|
||||
const title = prompt('Naam van het nieuwe hoofdstuk:', `Hoofdstuk ${chapters.length + 1}`);
|
||||
if (!title) return;
|
||||
if (isDirty) await saveCurrentChapter({ silent: true });
|
||||
await apiAddChapter(title, currentIdx);
|
||||
}
|
||||
|
||||
async function apiAddChapter(title, afterIndex) {
|
||||
const resp = await fetch(`/api/builder/${draftId}/chapter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, after_index: afterIndex }),
|
||||
});
|
||||
if (!resp.ok) { setStatus('error', 'Hoofdstuk toevoegen mislukt'); return; }
|
||||
const data = await resp.json();
|
||||
await refreshDraft(data.index);
|
||||
}
|
||||
|
||||
async function refreshDraft(targetIdx) {
|
||||
const resp = await fetch(`/api/builder/${draftId}`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
chapters = data.chapters;
|
||||
renderChapterList();
|
||||
loadChapter(Math.min(targetIdx ?? currentIdx, chapters.length - 1));
|
||||
}
|
||||
|
||||
// ── Delete chapter ────────────────────────────────────────────────────────────
|
||||
|
||||
async function deleteChapter(idx) {
|
||||
if (chapters.length <= 1) { alert('Kan het laatste hoofdstuk niet verwijderen.'); return; }
|
||||
if (!confirm(`Hoofdstuk "${chapters[idx].title}" verwijderen?`)) return;
|
||||
const resp = await fetch(`/api/builder/${draftId}/chapter/${idx}`, { method: 'DELETE' });
|
||||
if (!resp.ok) { setStatus('error', 'Verwijderen mislukt'); return; }
|
||||
const data = await resp.json();
|
||||
await refreshDraft(data.index);
|
||||
}
|
||||
|
||||
// ── Toolbar acties ────────────────────────────────────────────────────────────
|
||||
|
||||
function insertBreak() {
|
||||
const editor = getEditor();
|
||||
editor.focus();
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = '/static/break.png';
|
||||
img.style.height = '15px';
|
||||
img.className = 'scene-break';
|
||||
img.alt = '* * *';
|
||||
|
||||
const center = document.createElement('center');
|
||||
center.appendChild(img);
|
||||
|
||||
const p = document.createElement('p');
|
||||
p.innerHTML = '<br>';
|
||||
|
||||
range.insertNode(p);
|
||||
range.insertNode(center);
|
||||
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(p, 0);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function wrapBlockquote(cssClass) {
|
||||
const editor = getEditor();
|
||||
editor.focus();
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const selectedText = range.toString();
|
||||
if (!selectedText.trim()) return;
|
||||
|
||||
const bq = document.createElement('blockquote');
|
||||
if (cssClass) bq.className = cssClass;
|
||||
|
||||
const p = document.createElement('p');
|
||||
p.textContent = selectedText;
|
||||
bq.appendChild(p);
|
||||
|
||||
range.deleteContents();
|
||||
range.insertNode(bq);
|
||||
markDirty();
|
||||
}
|
||||
|
||||
// ── Normaliseer ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function normalizeChapter() {
|
||||
if (currentIdx < 0) return;
|
||||
const rawHtml = getEditor().innerHTML;
|
||||
|
||||
const resp = await fetch(`/api/builder/${draftId}/normalize/${currentIdx}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: rawHtml }),
|
||||
});
|
||||
if (!resp.ok) { setStatus('error', 'Normaliseren mislukt'); return; }
|
||||
const data = await resp.json();
|
||||
|
||||
if (!confirm('Normalisatie toepassen? Huidige inhoud wordt overschreven.')) return;
|
||||
getEditor().innerHTML = data.content;
|
||||
markDirty();
|
||||
await saveCurrentChapter({ silent: true });
|
||||
setStatus('saved', 'Genormaliseerd en opgeslagen');
|
||||
}
|
||||
|
||||
// ── Publiceer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function publishDraft() {
|
||||
if (isDirty) await saveCurrentChapter({ silent: true });
|
||||
if (!confirm('Dit boek publiceren en aan de library toevoegen?')) return;
|
||||
|
||||
const btn = document.getElementById('btn-publish');
|
||||
btn.disabled = true;
|
||||
setStatus('', 'Bezig met publiceren…');
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/builder/${draftId}/publish`, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
setStatus('error', err.error || 'Publiceren mislukt');
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
window.location.href = `/library/book/${encodeURIComponent(data.filename)}`;
|
||||
} catch {
|
||||
setStatus('error', 'Netwerkfout bij publiceren');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Draft verwijderen (overzichtspagina) ──────────────────────────────────────
|
||||
|
||||
function bindDraftDeleteButtons() {
|
||||
document.querySelectorAll('.draft-card-delete').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id;
|
||||
if (!confirm('Draft verwijderen?')) return;
|
||||
await fetch(`/api/builder/${id}`, { method: 'DELETE' });
|
||||
btn.closest('.draft-card').remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
89
containers/novela/static/conversion.js
Normal file
89
containers/novela/static/conversion.js
Normal 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();
|
||||
});
|
||||
}
|
||||
@ -1,10 +1,5 @@
|
||||
:root {
|
||||
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
|
||||
--border: #2e2a24; --accent: #c8783a; --text: #e8e2d9;
|
||||
--text-dim: #8a8278; --text-faint: #4a453e;
|
||||
--success: #6baa6b; --danger: #c85a5a;
|
||||
--radius: 6px;
|
||||
--mono: 'DM Mono', monospace;
|
||||
--danger: #c85a5a;
|
||||
--header-h: 50px;
|
||||
--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;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.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);
|
||||
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 {
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
|
||||
@ -1,20 +1,26 @@
|
||||
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 chapters = [];
|
||||
let currentIndex = -1;
|
||||
let dirty = new Set(); // indices with unsaved changes
|
||||
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 saving = false;
|
||||
|
||||
// ── Init Monaco ───────────────────────────────────────────────────────────────
|
||||
|
||||
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'), {
|
||||
language: 'xml',
|
||||
language: is_db ? 'html' : 'xml',
|
||||
theme: 'vs-dark',
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: true },
|
||||
@ -39,6 +45,19 @@ require(['vs/editor/editor.main'], function () {
|
||||
// Ctrl+S / Cmd+S
|
||||
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();
|
||||
});
|
||||
|
||||
@ -57,6 +76,7 @@ async function loadChapterList(targetIndex = 0) {
|
||||
currentIndex = -1;
|
||||
dirty.clear();
|
||||
pendingContent.clear();
|
||||
pendingTitles.clear();
|
||||
renderChapterList();
|
||||
document.getElementById('header-chapter').textContent = 'No chapters';
|
||||
document.getElementById('btn-save').disabled = true;
|
||||
@ -94,6 +114,11 @@ async function switchChapter(index) {
|
||||
if (dirty.has(currentIndex) && editor) {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -102,19 +127,19 @@ async function loadChapter(index) {
|
||||
document.getElementById('btn-save').disabled = true;
|
||||
document.getElementById('btn-break').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;
|
||||
|
||||
if (pendingContent.has(index)) {
|
||||
content = pendingContent.get(index);
|
||||
title = chapters[index]?.title ?? '';
|
||||
title = pendingTitles.has(index) ? pendingTitles.get(index) : (chapters[index]?.title ?? '');
|
||||
} else {
|
||||
const resp = await fetch(`/api/edit/chapter/${index}/${encodeURIComponent(filename)}`);
|
||||
if (!resp.ok) { setStatus('error', 'Load failed'); return; }
|
||||
const data = await resp.json();
|
||||
content = data.content;
|
||||
title = data.title;
|
||||
title = pendingTitles.has(index) ? pendingTitles.get(index) : data.title;
|
||||
}
|
||||
|
||||
currentIndex = index;
|
||||
@ -123,6 +148,7 @@ async function loadChapter(index) {
|
||||
editor.setValue(content);
|
||||
editor.setScrollTop(0);
|
||||
loadingChapter = false;
|
||||
editor.focus();
|
||||
|
||||
// Restore dirty state based on whether we loaded from pending cache
|
||||
if (dirty.has(index)) {
|
||||
@ -134,7 +160,11 @@ async function loadChapter(index) {
|
||||
}
|
||||
|
||||
renderChapterList();
|
||||
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-del-page').disabled = chapters.length <= 1;
|
||||
updateSaveAll();
|
||||
@ -149,18 +179,28 @@ async function saveChapter() {
|
||||
setStatus('saving', 'Saving…');
|
||||
|
||||
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(
|
||||
`/api/edit/chapter/${currentIndex}/${encodeURIComponent(filename)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: editor.getValue() }),
|
||||
body: JSON.stringify(saveBody),
|
||||
}
|
||||
);
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
dirty.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();
|
||||
setStatus('saved', 'Saved');
|
||||
setTimeout(() => setStatus('', ''), 2000);
|
||||
@ -186,9 +226,13 @@ async function saveAllChapters() {
|
||||
if (btn) btn.disabled = true;
|
||||
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)) {
|
||||
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];
|
||||
@ -196,21 +240,29 @@ async function saveAllChapters() {
|
||||
const content = pendingContent.has(i)
|
||||
? pendingContent.get(i)
|
||||
: (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 {
|
||||
const saveBody = { content: content || '' };
|
||||
if (is_db) saveBody.title = pendingTitles.has(i) ? pendingTitles.get(i) : (chapters[i]?.title || '');
|
||||
const resp = await fetch(
|
||||
`/api/edit/chapter/${i}/${encodeURIComponent(filename)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
body: JSON.stringify(saveBody),
|
||||
}
|
||||
);
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
dirty.delete(i);
|
||||
pendingContent.delete(i);
|
||||
if (is_db && chapters[i]) {
|
||||
chapters[i].title = pendingTitles.get(i) || chapters[i].title;
|
||||
pendingTitles.delete(i);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setStatus('error', `Save failed on chapter ${i + 1}`);
|
||||
@ -251,10 +303,11 @@ function updateSaveAll() {
|
||||
|
||||
function insertBreak() {
|
||||
if (!editor || currentIndex < 0) return;
|
||||
const breakSrc = is_db ? '/static/break.png' : '../Images/break.png';
|
||||
const pos = editor.getPosition();
|
||||
editor.executeEdits('insert-break', [{
|
||||
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,
|
||||
}]);
|
||||
editor.focus();
|
||||
@ -286,6 +339,7 @@ async function addChapter() {
|
||||
|
||||
dirty.clear();
|
||||
pendingContent.clear();
|
||||
pendingTitles.clear();
|
||||
await loadChapterList(data.index ?? Math.max(currentIndex + 1, 0));
|
||||
setStatus('saved', 'Page added');
|
||||
setTimeout(() => setStatus('', ''), 1500);
|
||||
@ -315,6 +369,7 @@ async function deleteChapter() {
|
||||
|
||||
dirty.clear();
|
||||
pendingContent.clear();
|
||||
pendingTitles.clear();
|
||||
await loadChapterList(data.index ?? Math.max(currentIndex - 1, 0));
|
||||
setStatus('saved', 'Page deleted');
|
||||
setTimeout(() => setStatus('', ''), 1500);
|
||||
@ -424,7 +479,3 @@ function setStatus(cls, text) {
|
||||
el.className = 'save-status' + (cls ? ' ' + cls : '');
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
BIN
containers/novela/static/favicon-256.png
Normal file
BIN
containers/novela/static/favicon-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
containers/novela/static/favicon-32.png
Normal file
BIN
containers/novela/static/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
containers/novela/static/favicon.ico
Normal file
BIN
containers/novela/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 762 B |
@ -1,24 +1,5 @@
|
||||
/* ── 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; }
|
||||
|
||||
html, body {
|
||||
@ -59,6 +40,18 @@ html, body {
|
||||
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 {
|
||||
border: 1px dashed var(--border);
|
||||
background: rgba(34, 31, 27, 0.45);
|
||||
@ -71,7 +64,7 @@ html, body {
|
||||
.import-dropzone:hover { border-color: var(--accent); }
|
||||
.import-dropzone.dragover {
|
||||
border-color: var(--accent2);
|
||||
background: rgba(200, 120, 58, 0.12);
|
||||
background: rgba(255, 162, 14, 0.12);
|
||||
}
|
||||
.import-dropzone.uploading {
|
||||
opacity: 0.8;
|
||||
@ -139,10 +132,13 @@ html, body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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-ongoing { background: rgba(200,160,58,0.18); color: var(--warning); }
|
||||
.badge-hiatus { background: rgba(200,160,58,0.18); color: var(--warning); }
|
||||
.badge-complete { color: #6baa6b; }
|
||||
.badge-ongoing { color: #4a90b8; }
|
||||
.badge-temporary-hold { color: #c8a03a; }
|
||||
.badge-long-term-hold { color: #ffa20e; }
|
||||
|
||||
/* Star: want-to-read top-left */
|
||||
.btn-star {
|
||||
@ -152,7 +148,7 @@ html, body {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: rgba(15,14,12,0.6);
|
||||
background: rgba(15,14,12,0.82);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -162,8 +158,9 @@ html, body {
|
||||
transition: color 0.15s, background 0.15s;
|
||||
padding: 0;
|
||||
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); }
|
||||
|
||||
/* Book info below cover */
|
||||
@ -201,7 +198,7 @@ html, body {
|
||||
/* Read count pill */
|
||||
.read-pill {
|
||||
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;
|
||||
padding: 0.1rem 0.38rem; border-radius: 3px; z-index: 2; pointer-events: none;
|
||||
}
|
||||
@ -210,7 +207,7 @@ html, body {
|
||||
.progress-mini {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
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); }
|
||||
|
||||
@ -436,13 +433,13 @@ html, body {
|
||||
}
|
||||
|
||||
.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);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.publisher-missing-item {
|
||||
background: rgba(200, 120, 58, 0.08);
|
||||
background: rgba(255, 162, 14, 0.08);
|
||||
}
|
||||
|
||||
.publisher-divider {
|
||||
@ -455,6 +452,36 @@ html, body {
|
||||
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-controls {
|
||||
@ -496,8 +523,8 @@ html, body {
|
||||
}
|
||||
|
||||
.btn.btn-view.active {
|
||||
border-color: rgba(200, 120, 58, 0.45);
|
||||
background: rgba(200, 120, 58, 0.16);
|
||||
border-color: rgba(255, 162, 14, 0.45);
|
||||
background: rgba(255, 162, 14, 0.16);
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
@ -521,6 +548,21 @@ html, body {
|
||||
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 {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
@ -602,7 +644,7 @@ html, body {
|
||||
}
|
||||
|
||||
.new-list-table tbody tr:hover {
|
||||
background: rgba(200, 120, 58, 0.08);
|
||||
background: rgba(255, 162, 14, 0.08);
|
||||
}
|
||||
|
||||
.new-col-select {
|
||||
@ -637,3 +679,114 @@ html, body {
|
||||
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
BIN
containers/novela/static/logo.png
Normal file
BIN
containers/novela/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@ -24,6 +24,16 @@ html {
|
||||
border-bottom: 1px solid var(--border);
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
@ -31,13 +41,6 @@ html {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.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 {
|
||||
font-family: var(--mono);
|
||||
@ -69,8 +72,10 @@ html {
|
||||
text-decoration: none;
|
||||
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.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-count {
|
||||
@ -89,6 +94,25 @@ html {
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -109,6 +133,43 @@ html {
|
||||
.btn-rescan:hover { background: var(--surface2); color: var(--text); }
|
||||
.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 ──────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-toggle {
|
||||
|
||||
20
containers/novela/static/theme.css
Normal file
20
containers/novela/static/theme.css
Normal 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;
|
||||
}
|
||||
@ -9,7 +9,10 @@
|
||||
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<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>
|
||||
|
||||
<ul class="sidebar-nav">
|
||||
@ -60,6 +63,16 @@
|
||||
<span class="sidebar-count" id="count-new"></span>
|
||||
</a>
|
||||
</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>
|
||||
<a href="{% if active == 'library' %}#{% else %}/library#series{% 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>
|
||||
</a>
|
||||
</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>
|
||||
<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">
|
||||
@ -123,6 +174,24 @@
|
||||
|
||||
<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>
|
||||
<ul class="sidebar-nav">
|
||||
<li>
|
||||
@ -133,6 +202,24 @@
|
||||
Convert
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/builder"{% if active == 'builder' %} class="active"{% endif %}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
||||
</svg>
|
||||
Book Builder
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/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>
|
||||
<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">
|
||||
@ -168,9 +255,29 @@
|
||||
Settings
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
<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"/>
|
||||
@ -203,6 +310,17 @@
|
||||
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 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 el = document.getElementById(id);
|
||||
@ -215,7 +333,10 @@
|
||||
setCount('count-series', seriesCount);
|
||||
setCount('count-authors', authorCount);
|
||||
setCount('count-publishers', publisherCount);
|
||||
setCount('count-rated', ratedCount);
|
||||
setCount('count-archived', archivedCount);
|
||||
setCount('count-duplicates', dupCount);
|
||||
setCount('count-incomplete', incompleteCount);
|
||||
}
|
||||
|
||||
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() {
|
||||
const btn = document.getElementById('rescan-btn');
|
||||
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();
|
||||
refreshBookmarkCount();
|
||||
refreshFollowingCount();
|
||||
loadBackupStatus();
|
||||
checkDiskUsage();
|
||||
setInterval(checkDiskUsage, 60_000);
|
||||
</script>
|
||||
|
||||
@ -4,17 +4,17 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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>
|
||||
:root {
|
||||
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
|
||||
--border: #2e2a24; --accent: #c8783a;
|
||||
--text: #e8e2d9; --text-dim: #8a8278;
|
||||
--ok: #7fbe7f; --warn: #d2b063; --err: #d0674c;
|
||||
--sidebar: 220px; --radius: 8px;
|
||||
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||
@ -87,7 +87,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.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; }
|
||||
|
||||
.field-label {
|
||||
@ -135,8 +135,46 @@
|
||||
|
||||
<section class="card">
|
||||
<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 -->
|
||||
<div id="oauth-step1">
|
||||
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
|
||||
Enter your App Key and App Secret from the
|
||||
<a href="https://www.dropbox.com/developers/apps" target="_blank" style="color:var(--accent);">Dropbox Developer Console</a>.
|
||||
Then click <strong>Generate Auth URL</strong> and follow the instructions.
|
||||
</p>
|
||||
<label class="field-label" for="app-key">App Key</label>
|
||||
<input class="field-input" id="app-key" type="text" placeholder="fyh6wd677d54ger"
|
||||
autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other"/>
|
||||
<label class="field-label" for="app-secret">App Secret</label>
|
||||
<input class="field-input" id="app-secret" type="password" placeholder="App secret"
|
||||
autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other"/>
|
||||
<div class="actions">
|
||||
<button class="btn primary" onclick="oauthPrepare()">Generate Auth URL</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 & 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>
|
||||
@ -149,11 +187,11 @@
|
||||
<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 Token</button>
|
||||
<button class="btn" onclick="toggleDropboxToken()">Show / Hide</button>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@ -198,13 +236,47 @@
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<script src="/static/books.js"></script>
|
||||
<script>
|
||||
function esc(v) {
|
||||
return String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function rowHtml(k, v) {
|
||||
return `<div class="row"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div></div>`;
|
||||
}
|
||||
@ -277,7 +349,6 @@
|
||||
|
||||
async function loadDropboxSettings() {
|
||||
const out = document.getElementById('dropbox-status');
|
||||
const tokenEl = document.getElementById('dropbox-token');
|
||||
const rootEl = document.getElementById('dropbox-root');
|
||||
const retentionEl = document.getElementById('retention-count');
|
||||
const scheduleEnabledEl = document.getElementById('schedule-enabled');
|
||||
@ -287,14 +358,14 @@
|
||||
try {
|
||||
const r = await fetch('/api/backup/credentials');
|
||||
const d = await r.json();
|
||||
tokenEl.value = '';
|
||||
rootEl.value = d.dropbox_root || '/novela';
|
||||
retentionEl.value = d.retention_count ?? 14;
|
||||
scheduleEnabledEl.value = String(!!d.schedule_enabled);
|
||||
scheduleHoursEl.value = d.schedule_interval_hours ?? 24;
|
||||
if (d.configured) {
|
||||
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 {
|
||||
out.className = 'status-line warn';
|
||||
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() {
|
||||
const out = document.getElementById('dropbox-status');
|
||||
const token = (document.getElementById('dropbox-token').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 scheduleEnabled = document.getElementById('schedule-enabled').value === 'true';
|
||||
@ -319,23 +456,20 @@
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
dropbox_root: dropboxRoot,
|
||||
retention_count: retentionCount,
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_interval_hours: scheduleIntervalHours
|
||||
schedule_interval_hours: scheduleIntervalHours,
|
||||
}),
|
||||
});
|
||||
const raw = await r.text();
|
||||
let d;
|
||||
try {
|
||||
d = JSON.parse(raw);
|
||||
} catch (_) {
|
||||
try { d = JSON.parse(raw); } catch (_) {
|
||||
throw new Error(`HTTP ${r.status}: ${raw.slice(0, 180) || 'non-JSON response'}`);
|
||||
}
|
||||
if (!d.ok) throw new Error(d.error || 'save failed');
|
||||
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()]);
|
||||
} catch (e) {
|
||||
out.className = 'status-line err';
|
||||
@ -352,7 +486,8 @@
|
||||
await fetch('/api/backup/credentials', {method: 'DELETE'});
|
||||
out.className = 'status-line ok';
|
||||
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('retention-count').value = 14;
|
||||
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) {
|
||||
const btnDry = document.getElementById('btn-dry');
|
||||
const btnLive = document.getElementById('btn-live');
|
||||
@ -390,6 +520,8 @@
|
||||
out.className = 'status-line ok';
|
||||
if (d.status === 'running') {
|
||||
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 {
|
||||
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() {
|
||||
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">✓ 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();
|
||||
|
||||
@ -4,8 +4,13 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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/book.css"/>
|
||||
</head>
|
||||
@ -25,6 +30,13 @@
|
||||
{% endif %}
|
||||
</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()">
|
||||
<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"/>
|
||||
@ -42,7 +54,7 @@
|
||||
{% if series %}
|
||||
<div class="meta-row">
|
||||
<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>
|
||||
{% endif %}
|
||||
<div class="meta-row">
|
||||
@ -58,7 +70,7 @@
|
||||
<span class="meta-label">Status</span>
|
||||
<span class="meta-value">
|
||||
{% 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 }}
|
||||
</span>
|
||||
</span>
|
||||
@ -143,6 +155,16 @@
|
||||
Mark as unread
|
||||
</button>
|
||||
{% 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 }}">
|
||||
<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"/>
|
||||
@ -151,6 +173,7 @@
|
||||
</svg>
|
||||
Download
|
||||
</a>
|
||||
{% endif %}
|
||||
<button class="btn-secondary" onclick="openMarkReadModal()">
|
||||
<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"/>
|
||||
@ -172,6 +195,7 @@
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
{% if filename.endswith('.epub') and 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">
|
||||
<polyline points="16 18 22 12 16 6"/>
|
||||
@ -179,6 +203,24 @@
|
||||
</svg>
|
||||
Edit EPUB
|
||||
</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)"/>
|
||||
<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">
|
||||
@ -213,11 +255,29 @@
|
||||
</button>
|
||||
</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"><label class="edit-label">Publisher</label><input class="edit-input" id="ed-publisher" type="text"/></div>
|
||||
<div class="edit-field">
|
||||
<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-field"><label class="edit-label">Series</label><input class="edit-input" id="ed-series" type="text"/></div>
|
||||
<div class="edit-field"><label class="edit-label">Volume</label><input class="edit-input" id="ed-series-index" type="number" min="0"/></div>
|
||||
<div class="edit-field">
|
||||
<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 class="edit-field">
|
||||
<label class="edit-label">Status</label>
|
||||
@ -225,7 +285,8 @@
|
||||
<option value="">—</option>
|
||||
<option value="Complete">Complete</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>
|
||||
</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">
|
||||
<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">
|
||||
<button class="btn-secondary" onclick="document.getElementById('delete-modal').classList.remove('open')">Cancel</button>
|
||||
<button class="btn-danger" onclick="confirmDelete()">Delete</button>
|
||||
@ -297,6 +358,8 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const STORAGE_TYPE = {{ storage_type | tojson }};
|
||||
|
||||
const BOOK = {
|
||||
filename: {{ filename | tojson }},
|
||||
title: {{ (title or filename) | tojson }},
|
||||
@ -304,6 +367,7 @@
|
||||
publisher: {{ (publisher or '') | tojson }},
|
||||
series: {{ (series or '') | tojson }},
|
||||
series_index: {{ series_index or 0 }},
|
||||
series_suffix: {{ (series_suffix or '') | tojson }},
|
||||
publication_status: {{ (publication_status or '') | tojson }},
|
||||
source_url: {{ (source_url or '') | tojson }},
|
||||
publish_date: {{ (publish_date or '') | tojson }},
|
||||
@ -312,8 +376,33 @@
|
||||
subgenres: {{ subgenres | tojson }},
|
||||
tags: {{ tags | tojson }},
|
||||
has_cover: {{ 'true' if has_cover else 'false' }},
|
||||
rating: {{ rating or 0 }},
|
||||
};
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
134
containers/novela/templates/builder.html
Normal file
134
containers/novela/templates/builder.html
Normal 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 }} · {{ d.updated_at.strftime('%d %b %Y %H:%M') }}</div>
|
||||
<button class="draft-card-delete" data-id="{{ d.id }}" title="Draft verwijderen">✕</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif view == 'editor' %}
|
||||
<div class="builder-editor" id="builder-editor">
|
||||
|
||||
<div class="builder-header">
|
||||
<a class="builder-back" href="/builder">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
Drafts
|
||||
</a>
|
||||
<div class="builder-header-title">{{ draft.title }}</div>
|
||||
<div class="builder-header-actions">
|
||||
<span class="save-status" id="save-status"></span>
|
||||
<button class="btn-publish" id="btn-publish" onclick="publishDraft()">Publiceer als EPUB</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="builder-toolbar" id="builder-toolbar">
|
||||
<button class="tb-btn" data-cmd="bold" title="Vet (Ctrl+B)"><strong>B</strong></button>
|
||||
<button class="tb-btn" data-cmd="italic" title="Cursief (Ctrl+I)"><em>I</em></button>
|
||||
<button class="tb-btn" data-cmd="underline" title="Onderstrepen (Ctrl+U)"><u>U</u></button>
|
||||
<div class="tb-sep"></div>
|
||||
<button class="tb-btn" title="Citaat" onclick="wrapBlockquote('')">❝</button>
|
||||
<button class="tb-btn" title="Auteur-noot" onclick="wrapBlockquote('author-note')">✍</button>
|
||||
<div class="tb-sep"></div>
|
||||
<button class="tb-btn" title="Scene break" onclick="insertBreak()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="12" x2="9" y2="12"/>
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none"/>
|
||||
<line x1="15" y1="12" x2="21" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="tb-sep"></div>
|
||||
<button class="tb-btn tb-normalize" onclick="normalizeChapter()" title="Normaliseer huidige hoofdstuk">Normaliseer</button>
|
||||
</div>
|
||||
|
||||
<div class="builder-body">
|
||||
<nav class="builder-chapter-panel">
|
||||
<div class="chapter-panel-title">Hoofdstukken</div>
|
||||
<div class="chapter-list" id="chapter-list"></div>
|
||||
<button class="btn-add-chapter" onclick="addChapter()">+ Hoofdstuk</button>
|
||||
</nav>
|
||||
<div class="builder-editor-pane">
|
||||
<div
|
||||
class="builder-content"
|
||||
id="builder-content"
|
||||
contenteditable="true"
|
||||
spellcheck="true"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const BUILDER = {
|
||||
draftId: {{ draft.id | tojson }},
|
||||
title: {{ draft.title | tojson }},
|
||||
chapters: {{ draft.chapters | tojson }},
|
||||
};
|
||||
</script>
|
||||
<script src="/static/builder.js"></script>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
937
containers/novela/templates/bulk_import.html
Normal file
937
containers/novela/templates/bulk_import.html
Normal 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 — <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 += ` <span class="cnt-warn">${warnCount} to check</span>`;
|
||||
if (dupCount) {
|
||||
stats += ` <span class="cnt-dup">${dupCount} duplicate${dupCount !== 1 ? 's' : ''}</span>`;
|
||||
stats += ` <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,'"')}">${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>
|
||||
130
containers/novela/templates/changelog.html
Normal file
130
containers/novela/templates/changelog.html
Normal 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>
|
||||
@ -4,29 +4,15 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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>
|
||||
: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; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
@ -295,6 +281,7 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="/static/books.js"></script>
|
||||
<script>
|
||||
let allCredentials = {};
|
||||
|
||||
|
||||
@ -4,29 +4,15 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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>
|
||||
: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; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
@ -178,6 +164,7 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="/static/books.js"></script>
|
||||
<script>
|
||||
async function runInspect() {
|
||||
const url = document.getElementById('url').value.trim();
|
||||
@ -226,10 +213,6 @@
|
||||
toggle.textContent = collapsed ? '▼ expand' : '▲ collapse';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function metaRow(label, value) {
|
||||
return `<div class="meta-row"><span class="meta-label">${label}</span><span class="meta-value">${value}</span></div>`;
|
||||
|
||||
@ -4,8 +4,13 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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"/>
|
||||
</head>
|
||||
<body>
|
||||
@ -19,6 +24,7 @@
|
||||
{{ (title or filename) | truncate(30, True) }}
|
||||
</a>
|
||||
<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">
|
||||
<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">
|
||||
@ -89,9 +95,11 @@
|
||||
const EDITOR = {
|
||||
filename: {{ filename | tojson }},
|
||||
title: {{ (title or filename) | tojson }},
|
||||
is_db: {{ is_db | tojson }},
|
||||
};
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
309
containers/novela/templates/following.html
Normal file
309
containers/novela/templates/following.html
Normal 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>
|
||||
@ -4,29 +4,15 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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>
|
||||
: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; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
@ -227,6 +213,34 @@
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
@ -239,7 +253,7 @@
|
||||
<div class="card">
|
||||
<div class="card-title">Book URL</div>
|
||||
<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>
|
||||
<button id="load-btn" onclick="loadMeta()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
@ -250,6 +264,9 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Duplicate warning -->
|
||||
<div class="dup-warning" id="dup-warning"></div>
|
||||
|
||||
<!-- Step 2: Metadata preview + cover upload + Convert -->
|
||||
<div class="card" id="meta-card">
|
||||
<div class="card-title">Book info</div>
|
||||
@ -268,6 +285,12 @@
|
||||
<div class="cover-filename" id="cover-filename"></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()">
|
||||
<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"/>
|
||||
@ -297,7 +320,7 @@
|
||||
<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"/>
|
||||
</svg>
|
||||
Download EPUB
|
||||
<span>Download EPUB</span>
|
||||
</button>
|
||||
<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">
|
||||
@ -310,9 +333,18 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="/static/books.js"></script>
|
||||
<script src="/static/conversion.js"></script>
|
||||
<script>
|
||||
let currentUrl = '';
|
||||
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 ---
|
||||
async function checkUrlCredentials() {
|
||||
@ -362,6 +394,7 @@
|
||||
}
|
||||
|
||||
renderMeta(d);
|
||||
showDupWarning(d.already_exists ? d.existing_books : []);
|
||||
document.getElementById('meta-card').classList.add('visible');
|
||||
// Reset cover upload
|
||||
document.getElementById('cover-file').value = '';
|
||||
@ -455,7 +488,7 @@
|
||||
document.getElementById('log-lines').innerHTML = '';
|
||||
document.getElementById('progress-bar').style.width = '0%';
|
||||
|
||||
const body = { url: currentUrl };
|
||||
const body = { url: currentUrl, storage_mode: storageMode };
|
||||
if (coverB64) body.cover_b64 = coverB64;
|
||||
const seriesInput = document.getElementById('series-index-input');
|
||||
if (seriesInput) body.series_index = parseInt(seriesInput.value) || 1;
|
||||
@ -472,91 +505,25 @@
|
||||
document.getElementById('convert-label').textContent = 'Convert';
|
||||
document.getElementById('convert-spinner').style.display = 'none';
|
||||
|
||||
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`;
|
||||
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();
|
||||
});
|
||||
connectConversionStream(job_id);
|
||||
}
|
||||
|
||||
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 clearDupWarning() {
|
||||
const el = document.getElementById('dup-warning');
|
||||
el.classList.remove('visible');
|
||||
el.innerHTML = '';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
function showDupWarning(books) {
|
||||
const el = document.getElementById('dup-warning');
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -4,18 +4,15 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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>
|
||||
: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; }
|
||||
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.dragover {
|
||||
border-color: var(--accent2);
|
||||
background: rgba(200, 120, 58, 0.12);
|
||||
background: rgba(255, 162, 14, 0.12);
|
||||
}
|
||||
.import-dropzone.uploading {
|
||||
opacity: 0.8;
|
||||
@ -130,7 +127,7 @@
|
||||
font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim);
|
||||
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-pct { font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim); }
|
||||
|
||||
@ -163,7 +160,7 @@
|
||||
.progress-mini {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
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); }
|
||||
.book-info { padding: 0.5rem 0.2rem 0; }
|
||||
@ -175,6 +172,11 @@
|
||||
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
||||
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 {
|
||||
text-align: center; color: var(--text-faint); font-family: var(--mono);
|
||||
@ -282,6 +284,7 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/books.js"></script>
|
||||
<script>
|
||||
let data = { continue_reading: [], shorts_unread: [], novels_unread: [], shorts_read: [], novels_read: [] };
|
||||
let currentView = 'home';
|
||||
@ -290,54 +293,36 @@
|
||||
let allBooks = [];
|
||||
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
|
||||
|
||||
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 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function jsEsc(s) { return String(s || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
|
||||
function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); }
|
||||
function filenameBase(filename) {
|
||||
const leaf = String(filename || '').split('/').pop() || '';
|
||||
return leaf.replace(/\.[^.]+$/, '');
|
||||
|
||||
function starsHtml(filename, rating) {
|
||||
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>`;
|
||||
}
|
||||
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, ' ');
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
async function rateBook(filename, rating) {
|
||||
const book = allBooks.find(b => b.filename === filename);
|
||||
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) {
|
||||
@ -349,11 +334,11 @@
|
||||
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
||||
img.alt = title;
|
||||
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.insertBefore(img, coverEl.firstChild);
|
||||
}
|
||||
requestAnimationFrame(() => makePlaceholder(canvasEl, title, author));
|
||||
requestAnimationFrame(() => makePlaceholderCover(canvasEl, title, author));
|
||||
}
|
||||
|
||||
function makeHCard(b, showProgress) {
|
||||
@ -368,6 +353,7 @@
|
||||
<div class="h-cover" id="hc-${id}">
|
||||
<canvas id="hcv-${id}" style="width:100%;height:100%;display:block"></canvas>
|
||||
</div>
|
||||
${starsHtml(b.filename, b.rating)}
|
||||
<div class="h-info">
|
||||
<div class="h-title">${esc(title)}</div>
|
||||
${showProgress
|
||||
@ -393,6 +379,7 @@
|
||||
: ''}
|
||||
</a>
|
||||
</div>
|
||||
${starsHtml(b.filename, b.rating)}
|
||||
<div class="book-info">
|
||||
<div class="book-title">${esc(title)}</div>
|
||||
<div class="book-author">${esc(author)}</div>
|
||||
@ -430,22 +417,16 @@
|
||||
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
||||
img.alt = bookTitle(b);
|
||||
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';
|
||||
canvas.parentElement.insertBefore(img, canvas);
|
||||
}
|
||||
requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b)));
|
||||
requestAnimationFrame(() => makePlaceholderCover(canvas, bookTitle(b), bookAuthor(b)));
|
||||
});
|
||||
}
|
||||
|
||||
function searchResults(query) {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
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))
|
||||
);
|
||||
return filterBooks(allBooks, query);
|
||||
}
|
||||
|
||||
function switchView(view) {
|
||||
@ -491,16 +472,9 @@
|
||||
}
|
||||
|
||||
function setupSearch() {
|
||||
const input = document.getElementById('home-search-input');
|
||||
const clear = document.getElementById('home-search-clear');
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim();
|
||||
clear.style.display = q ? '' : 'none';
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
setupSearchInput('home-search-input', 'home-search-clear', q => {
|
||||
if (q) switchView('search');
|
||||
else if (currentView === 'search') switchView('home');
|
||||
}, 180);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -4,29 +4,15 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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>
|
||||
: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; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
@ -310,6 +296,8 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="/static/books.js"></script>
|
||||
<script src="/static/conversion.js"></script>
|
||||
<script>
|
||||
let currentUrl = '';
|
||||
let coverB64 = null;
|
||||
@ -472,91 +460,9 @@
|
||||
document.getElementById('convert-label').textContent = 'Convert';
|
||||
document.getElementById('convert-spinner').style.display = 'none';
|
||||
|
||||
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`;
|
||||
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();
|
||||
});
|
||||
connectConversionStream(job_id);
|
||||
}
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -4,8 +4,13 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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/library.css"/>
|
||||
</head>
|
||||
@ -36,6 +41,7 @@
|
||||
<div class="import-title">Drop EPUB, PDF or CBR/CBZ files here</div>
|
||||
<div class="import-sub">or click to choose files</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="grid-container">
|
||||
<div class="empty">Loading…</div>
|
||||
@ -54,6 +60,24 @@
|
||||
</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 -->
|
||||
<div class="overlay" id="cover-overlay">
|
||||
<div class="dialog">
|
||||
@ -74,6 +98,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/books.js"></script>
|
||||
<script src="/static/library.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -4,15 +4,15 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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>
|
||||
: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;
|
||||
--content-w: 65vw;
|
||||
}
|
||||
@ -42,6 +42,7 @@
|
||||
color: var(--text-dim); text-decoration: none;
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
margin-left: 1rem;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.header-back:hover { color: var(--text); }
|
||||
@ -66,6 +67,58 @@
|
||||
.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: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-overlay {
|
||||
@ -107,6 +160,18 @@
|
||||
background: transparent; cursor: pointer;
|
||||
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 {
|
||||
@ -213,6 +278,20 @@
|
||||
</head>
|
||||
<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 -->
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
@ -231,6 +310,16 @@
|
||||
<input type="range" id="width-slider" min="30" max="100" step="1"
|
||||
value="65" oninput="applyWidth(this.value)"/>
|
||||
</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>
|
||||
|
||||
<!-- Header -->
|
||||
@ -250,6 +339,12 @@
|
||||
</a>
|
||||
<div class="header-title" id="header-title"></div>
|
||||
<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()">
|
||||
<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"/>
|
||||
@ -287,8 +382,10 @@
|
||||
<div class="footer-pct" id="footer-pct">0%</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/books.js"></script>
|
||||
<script>
|
||||
const filename = {{ filename | tojson }};
|
||||
const FORMAT = {{ format | tojson }};
|
||||
|
||||
let chapters = [];
|
||||
let currentIndex = 0;
|
||||
@ -309,6 +406,20 @@
|
||||
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 ────────────────────────────────────────────
|
||||
function toggleSettings() {
|
||||
const open = document.getElementById('settings-drawer').classList.toggle('open');
|
||||
@ -320,19 +431,19 @@
|
||||
document.getElementById('settings-overlay').classList.remove('open');
|
||||
}
|
||||
|
||||
// ── Progress (chapter + scroll within chapter) ─────────────────
|
||||
const IS_PAGED = (FORMAT === 'pdf' || FORMAT === 'cbr' || FORMAT === 'cbz');
|
||||
|
||||
// ── Progress ───────────────────────────────────────────────────
|
||||
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 pct = total > 1
|
||||
? Math.round(((currentIndex + scrollFrac) / (total - 1)) * 100)
|
||||
: total === 1
|
||||
? Math.round(scrollFrac * 100)
|
||||
if (IS_PAGED) {
|
||||
const pct = total > 0 ? Math.round(((currentIndex + 1) / total) * 100) : 0;
|
||||
return { scrollFrac: 0, pct };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
@ -358,7 +469,55 @@
|
||||
}, 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) {
|
||||
if (index < 0 || index >= chapters.length) return;
|
||||
currentIndex = index;
|
||||
@ -367,7 +526,6 @@
|
||||
const html = await resp.text();
|
||||
document.getElementById('chapter-content').innerHTML = html;
|
||||
|
||||
// Restore scroll position within chapter (after DOM paint)
|
||||
if (scrollFrac && scrollFrac > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
@ -379,63 +537,92 @@
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// Update header
|
||||
const ch = chapters[index];
|
||||
document.getElementById('header-title').innerHTML =
|
||||
ch ? `<strong>${esc(ch.title)}</strong>` : '';
|
||||
|
||||
// Update nav
|
||||
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();
|
||||
}
|
||||
|
||||
function navigate(delta) {
|
||||
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', () => {
|
||||
updateFooter();
|
||||
if (!IS_PAGED) {
|
||||
clearTimeout(scrollTimer);
|
||||
scrollTimer = setTimeout(scheduleSave, 300);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// ── Keyboard navigation ────────────────────────────────────────
|
||||
document.addEventListener('keydown', (e) => {
|
||||
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 === 'Escape') closeSettings();
|
||||
if (e.key === 'Escape') { closeSettings(); closeBookmarkModal(); }
|
||||
});
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────
|
||||
async function init() {
|
||||
loadWidth();
|
||||
loadTextColour();
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
fetch(`/library/chapters/${encodeURIComponent(filename)}`),
|
||||
fetch(`/library/progress/${encodeURIComponent(filename)}`),
|
||||
]);
|
||||
chapters = await r1.json();
|
||||
const prog = await r2.json();
|
||||
const progResp = await fetch(`/library/progress/${encodeURIComponent(filename)}`);
|
||||
const prog = await progResp.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 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 idx = parseInt(parts[0], 10);
|
||||
if (!isNaN(idx) && idx >= 0 && idx < chapters.length) {
|
||||
if (!isNaN(idx) && idx >= 0) {
|
||||
startIndex = idx;
|
||||
startScroll = parseFloat(parts[1]) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
@ -445,10 +632,45 @@
|
||||
window.location.href = `/library/book/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
// ── Bookmarks ──────────────────────────────────────────────────
|
||||
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();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
149
containers/novela/templates/search.html
Normal file
149
containers/novela/templates/search.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
<script src="/static/books.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -4,18 +4,15 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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>
|
||||
: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; }
|
||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||
|
||||
@ -257,6 +254,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/books.js"></script>
|
||||
<script>
|
||||
// ── Break patterns ─────────────────────────────────────────────────────────
|
||||
let bpPatterns = [];
|
||||
@ -383,9 +381,6 @@
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Enter key in add inputs
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@ -4,19 +4,16 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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 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"/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<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; }
|
||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||
|
||||
@ -164,18 +161,16 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/books.js"></script>
|
||||
<script>
|
||||
Chart.defaults.color = '#8a8278';
|
||||
Chart.defaults.borderColor = '#2e2a24';
|
||||
Chart.defaults.font.family = "'DM Mono', monospace";
|
||||
Chart.defaults.font.size = 11;
|
||||
|
||||
const ACCENT = '#c8783a';
|
||||
const ACCENT_A = 'rgba(200,120,58,0.15)';
|
||||
const ACCENT = '#ffa20e';
|
||||
const ACCENT_A = 'rgba(255,162,14,0.15)';
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
const d = new Date(iso);
|
||||
|
||||
@ -167,3 +167,87 @@ def element_to_xhtml(el, break_img_path: str = "../Images/break.png", empty_p_is
|
||||
for c in el.children:
|
||||
parts.append(element_to_xhtml(c, break_img_path, empty_p_is_spacer))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def normalize_wysiwyg_html(raw_html: str, break_img_path: str = "../Images/break.png") -> str:
|
||||
"""Normaliseer HTML uit de WYSIWYG-editor naar EPUB-compatibele XHTML.
|
||||
|
||||
Vervangt scene-breaks door de break-afbeelding, behoudt <strong>, <em>, <u>,
|
||||
<blockquote>, <blockquote class="author-note">, wikkelt losse tekst in <p>,
|
||||
verwijdert lege paragrafen.
|
||||
"""
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
|
||||
soup = BeautifulSoup(raw_html or "", "html.parser")
|
||||
body = soup.find("body") or soup
|
||||
output_parts: list[str] = []
|
||||
|
||||
def process_inline(el) -> str:
|
||||
if isinstance(el, NavigableString):
|
||||
text = str(el)
|
||||
return he(text) if text else ""
|
||||
if el.name in ("strong", "b"):
|
||||
inner = "".join(process_inline(c) for c in el.children)
|
||||
return f"<strong>{inner}</strong>"
|
||||
if el.name in ("em", "i"):
|
||||
inner = "".join(process_inline(c) for c in el.children)
|
||||
return f"<em>{inner}</em>"
|
||||
if el.name == "u":
|
||||
inner = "".join(process_inline(c) for c in el.children)
|
||||
return f"<u>{inner}</u>"
|
||||
if el.name == "br":
|
||||
return "<br />"
|
||||
return "".join(process_inline(c) for c in el.children)
|
||||
|
||||
def process_block(el) -> str | None:
|
||||
if isinstance(el, NavigableString):
|
||||
text = str(el).strip()
|
||||
return f"<p>{he(text)}</p>" if text else None
|
||||
if not isinstance(el, Tag):
|
||||
return None
|
||||
if is_break_element(el):
|
||||
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
|
||||
if el.name == "img":
|
||||
src = el.get("src", "")
|
||||
alt = he(el.get("alt", ""))
|
||||
if "break" in src.lower():
|
||||
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
|
||||
return f'<img src="{he(src)}" alt="{alt}"/>' if src else None
|
||||
if el.name in ("p", "div"):
|
||||
if is_break_element(el):
|
||||
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
|
||||
inner = "".join(process_inline(c) for c in el.children).strip()
|
||||
return f"<p>{inner}</p>" if inner else None
|
||||
if el.name in ("h1", "h2", "h3", "h4"):
|
||||
inner = "".join(process_inline(c) for c in el.children).strip()
|
||||
return f"<{el.name}>{inner}</{el.name}>" if inner else None
|
||||
if el.name == "blockquote":
|
||||
classes = el.get("class", [])
|
||||
css_class = " ".join(classes) if classes else ""
|
||||
tag_open = f'<blockquote class="{css_class}">' if css_class else "<blockquote>"
|
||||
parts = []
|
||||
for child in el.children:
|
||||
if isinstance(child, NavigableString):
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
parts.append(f"<p>{he(text)}</p>")
|
||||
elif isinstance(child, Tag) and child.name in ("p", "div"):
|
||||
inner = "".join(process_inline(c) for c in child.children).strip()
|
||||
if inner:
|
||||
parts.append(f"<p>{inner}</p>")
|
||||
else:
|
||||
r = process_block(child)
|
||||
if r:
|
||||
parts.append(r)
|
||||
return f"{tag_open}{''.join(parts)}</blockquote>" if parts else None
|
||||
if el.name == "hr":
|
||||
return f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
|
||||
parts = [r for c in el.children if (r := process_block(c))]
|
||||
return "".join(parts) if parts else None
|
||||
|
||||
for child in list(body.children if hasattr(body, "children") else []):
|
||||
result = process_block(child)
|
||||
if result:
|
||||
output_parts.append(result)
|
||||
|
||||
return "\n".join(output_parts)
|
||||
|
||||
@ -16,29 +16,74 @@ It is the primary technical reference for the current implementation.
|
||||
2. `close_pool()`
|
||||
- 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 1–999.
|
||||
- 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
|
||||
|
||||
### `routers/library.py`
|
||||
- `GET /library`
|
||||
- `GET /api/library`
|
||||
- `POST /library/rescan`
|
||||
- `POST /library/import` (EPUB/PDF/CBR/CBZ)
|
||||
- `DELETE /library/file/{filename}`
|
||||
- `GET /library/cover/{filename}`
|
||||
- `GET /library/cover-cached/{filename}`
|
||||
- `POST /library/cover/{filename}` (EPUB)
|
||||
- `POST /library/want-to-read/{filename}`
|
||||
- `POST /library/archive/{filename}`
|
||||
- `POST /library/new/mark-reviewed` (bulk `needs_review=false`)
|
||||
- `GET /home`
|
||||
- `GET /api/home`
|
||||
- `GET /stats`
|
||||
- `GET /api/stats`
|
||||
- `GET /library/list` (compat)
|
||||
- `GET /library` — library page
|
||||
- `GET /api/library` — book list JSON (fast-path by default)
|
||||
- `POST /library/rescan` — forced full disk rescan
|
||||
- `POST /library/import` — upload EPUB/PDF/CBR/CBZ
|
||||
- `DELETE /library/file/{filename}` — delete file + DB row + prune dirs
|
||||
- `GET /download/{filename}` — download file with `Content-Disposition: attachment`
|
||||
- `GET /library/cover/{filename}` — serve cover (EPUB from file; PDF/CBR from cache)
|
||||
- `GET /library/cover-cached/{filename}` — serve cover from DB cache only
|
||||
- `POST /library/cover/{filename}` — upload/replace cover (EPUB only)
|
||||
- `POST /library/want-to-read/{filename}` — toggle want-to-read flag
|
||||
- `POST /library/archive/{filename}` — toggle archived flag
|
||||
- `POST /library/new/mark-reviewed` — bulk set `needs_review=false`
|
||||
- `POST /library/bulk-delete` — delete multiple files; accepts `{"filenames": [...]}`, removes files from disk and DB in one query per batch; returns `{ok, deleted, skipped}`
|
||||
- `POST /library/rating/{filename}` — set/clear star rating `{"rating": 0-5}`
|
||||
- `GET /home` — home page
|
||||
- `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).
|
||||
For a forced sync: `GET /api/library?rescan=true` or `POST /library/rescan`.
|
||||
`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:
|
||||
- `continue_reading`
|
||||
@ -61,43 +106,141 @@ Home read sections are ordered oldest-first:
|
||||
- `novels_read`: `ORDER BY MAX(read_at) ASC`
|
||||
|
||||
### `routers/reader.py`
|
||||
- EPUB serving/chapters/images
|
||||
- Reader page + book detail
|
||||
- Metadata patch (`PATCH /library/book/{filename}`)
|
||||
- Progress read/write/delete
|
||||
- Mark-as-read
|
||||
- PDF render endpoint
|
||||
- CBR/CBZ page endpoint
|
||||
- Genres endpoint
|
||||
- `GET /library/db-images/{path:path}` — serve image from content-addressed imagestore (`library/images/`); security: path must be under `IMAGES_DIR`
|
||||
- `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}`
|
||||
- `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`
|
||||
- `GET /library/epub/{filename}` — serve EPUB inline (no attachment header)
|
||||
- `GET /library/chapters/{filename}` — EPUB spine as JSON; for `storage_type='db'` books returns chapters from `book_chapters`
|
||||
- `GET /library/chapter/{index}/{filename}` — single chapter as HTML fragment; for `storage_type='db'` books reads from `book_chapters`
|
||||
- `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
|
||||
- `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 1–5 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`
|
||||
- Editor page
|
||||
- Chapter get/save
|
||||
- Chapter add
|
||||
- Chapter delete
|
||||
- `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)
|
||||
- `GET /api/edit/chapter/{index}/{filename}` — get chapter content; DB branch reads from `book_chapters` and returns `{index, href, title, content}`
|
||||
- `POST /api/edit/chapter/{index}/{filename}` — save chapter; DB branch accepts `{content, title}`, calls `upsert_chapter` (updates `content_tsv` too)
|
||||
- `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`
|
||||
- Grabber page + convert/debug flows
|
||||
- SSE events
|
||||
- Credential management for scraper sites
|
||||
- Credentials manager UI (`/credentials-manager`)
|
||||
- `GET /grabber` — grabber page
|
||||
- `GET /convert` — convert page
|
||||
- `GET /credentials-manager` — credentials manager UI
|
||||
- `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"`):
|
||||
1–2. 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`
|
||||
- `GET /backup`
|
||||
- `GET/POST/DELETE /api/backup/credentials`
|
||||
- `GET /api/backup/health`
|
||||
- `GET /api/backup/status`
|
||||
- `GET /api/backup/history`
|
||||
- `POST /api/backup/run`
|
||||
- `GET /backup` — backup page
|
||||
- `GET /api/backup/credentials` — Dropbox settings (includes `app_key_configured` flag)
|
||||
- `POST /api/backup/credentials` — save Dropbox settings
|
||||
- `DELETE /api/backup/credentials` — remove all Dropbox credentials
|
||||
- `POST /api/backup/oauth/prepare` — save app key + secret, return Dropbox auth URL
|
||||
- `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
|
||||
- Dropbox token is stored encrypted-at-rest in `credentials` (`site='dropbox'`).
|
||||
- Dropbox backup root is stored encrypted in `credentials` (`site='dropbox_backup_root'`).
|
||||
- Retention (`snapshots to keep`) is stored encrypted in `credentials` (`site='dropbox_backup_retention'`).
|
||||
- Backup schedule (`enabled` + `interval_hours`) is stored encrypted in `credentials` (`site='dropbox_backup_schedule'`).
|
||||
- Dropbox token (refresh token or legacy access token) stored encrypted in `credentials` (`site='dropbox'`).
|
||||
- Dropbox app key stored encrypted in `credentials` (`site='dropbox_app_key'`).
|
||||
- Dropbox app secret stored encrypted in `credentials` (`site='dropbox_app_secret'`).
|
||||
- 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).
|
||||
|
||||
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:
|
||||
- file objects in Dropbox: `library_objects/{sha256_prefix}/{sha256}`
|
||||
- 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.
|
||||
- Database backup is done via `pg_dump` to Dropbox `postgres/`.
|
||||
- `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.
|
||||
- Concurrency guard: only one backup can run at a time.
|
||||
- After container restart/crash, stale `running` logs are auto-marked as interrupted/error.
|
||||
|
||||
---
|
||||
|
||||
## Environment
|
||||
`stack/novela.env` should include at least:
|
||||
- `POSTGRES_DB`
|
||||
@ -121,6 +267,89 @@ Implementation details:
|
||||
|
||||
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
|
||||
- Library import accepts EPUB/PDF/CBR/CBZ.
|
||||
- Home supports the same import formats.
|
||||
@ -128,40 +357,133 @@ Dropbox settings are managed via the web UI on `/backup`.
|
||||
- Home header/dropzone alignment matches Library (search top-right, dropzone below).
|
||||
- `New` view supports `Grid` and `List` mode.
|
||||
- Bulk selection + `Remove from New` works only in `List` mode.
|
||||
- `List` mode has a column visibility filter with columns:
|
||||
- Publisher
|
||||
- Author
|
||||
- Series
|
||||
- Volume
|
||||
- Title
|
||||
- Has cover
|
||||
- Updated
|
||||
- Genres
|
||||
- Sub-genres
|
||||
- Tags
|
||||
- Status
|
||||
- `List` mode has a column visibility filter: Publisher, Author, Series, Volume, Title, Has cover, Updated, Genres, Sub-genres, Tags, Status.
|
||||
- `List` mode supports multi-select with `Shift+click` range selection on checkboxes.
|
||||
- `Grid` mode shows no selection checkboxes or bulk actions.
|
||||
- Backup page supports:
|
||||
- manual run and dry-run
|
||||
- Dropbox root settings
|
||||
- snapshot retention count
|
||||
- scheduled backup (on/off + interval in hours)
|
||||
- status + history overview
|
||||
- `All books` view supports `Grid` and `List` mode (same columns as `New`).
|
||||
- View mode persisted in `localStorage` as `novela.all.viewMode`.
|
||||
- Column visibility persisted in `localStorage` as `novela.all.visibleColumns`.
|
||||
- `List` mode has a checkbox column, column visibility filter, and multi-select with `Shift+click` range selection.
|
||||
- `List` mode has a `Delete selected` bulk action: confirms then calls `DELETE /library/file/{filename}` for each selected book.
|
||||
- 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()`.
|
||||
- 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.
|
||||
- Want-to-read star (top-left of grid card cover): same dark fill + ring as status badges.
|
||||
- Status pills in Book Detail (`book.css`): `status-complete`, `status-ongoing`, `status-temporary-hold`, `status-long-term-hold` — same colour scheme as badges.
|
||||
- Grabber status mapping (`grabber.py`): `Temporary-Hold` (gayauthors.org) → `Temporary Hold`; `Long-Term Hold` passes through unchanged.
|
||||
- Star ratings (1–5) shown under the cover in all grid views:
|
||||
- Display-only in grid cards (no click, prevents accidental taps while scrolling).
|
||||
- Interactive in Book Detail (1.1rem, clickable; clicking the active star clears the rating).
|
||||
- Amber: filled `#c8a03a`, unfilled `rgba(200, 160, 58, 0.25)`.
|
||||
- Reader settings (hamburger menu):
|
||||
- Content width slider (30–100 vw), persisted as `reader-content-width-pct`.
|
||||
- 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
|
||||
- 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:
|
||||
- EPUB: cover from file + cache
|
||||
- PDF/CBR: thumbnail via cover 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: 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
|
||||
- Library load is optimized for large datasets:
|
||||
- `list_library_json()` uses pre-aggregation for `reading_sessions`.
|
||||
- Library load is optimized for large datasets (1000+ books):
|
||||
- `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.
|
||||
- `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:
|
||||
- `idx_library_sort_coalesce`
|
||||
- `idx_library_needs_review`
|
||||
- `idx_library_archived`
|
||||
- `idx_reading_sessions_filename_readat`
|
||||
- `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.
|
||||
|
||||
@ -1,8 +1,251 @@
|
||||
# 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 4–6): 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 1–3): 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.
|
||||
`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
|
||||
- Added blueprint/technical documentation structure in `docs/`.
|
||||
- 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
|
||||
- Updated Docker image with `postgresql-client` for `pg_dump`.
|
||||
- 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 1–5 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
121
docs/changelog.md
Normal 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
|
||||
- 1–5 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 (30–100 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
1
version.txt
Normal file
@ -0,0 +1 @@
|
||||
v0.1.3
|
||||
Loading…
Reference in New Issue
Block a user