Compare commits

..

No commits in common. "main" and "v0.1.8" have entirely different histories.
main ... v0.1.8

48 changed files with 224 additions and 2796 deletions

View File

@ -3,179 +3,6 @@ Changelog data for Novela
""" """
CHANGELOG = [ CHANGELOG = [
{
"version": "v0.1.12",
"date": "2026-04-15",
"summary": "Font size slider in the reader settings drawer.",
"sections": [
{
"title": "New features",
"type": "feature",
"changes": [
"Reader: font size slider in the reading settings drawer — adjust text size from 80% to 150%; setting is saved per device so iPad and desktop each remember their own preference",
],
},
],
},
{
"version": "v0.1.11",
"date": "2026-04-13",
"summary": "Comma-separated values in genre, subgenre and tag inputs are now split into individual tags.",
"sections": [
{
"title": "Bug fixes",
"type": "bugfix",
"changes": [
"Edit metadata: pasting or typing a comma-separated list in the genre, subgenre or tag input now adds each value as a separate tag instead of one combined tag",
],
},
],
},
{
"version": "v0.1.10",
"date": "2026-04-12",
"summary": "Series navigation in the reader, series_volume support for annual comics, archive a series in one click, and a TedLouis scraper fix.",
"sections": [
{
"title": "New features",
"type": "feature",
"changes": [
"Reader: prev/next volume buttons in the header for books that are part of a series — buttons appear automatically when the book has adjacent volumes; tooltip shows the volume number and title; marking a book as read redirects directly to the next volume in the reader instead of the book detail page",
"Comics: series_volume field for annual series where issue numbers restart each year (e.g. Donald Duck (1982) [15]) — stored in the database and EPUB OPF; displayed as '(year)' after the series name on the book detail page; sorting respects series_volume before series_index; supported in Bulk Import via %series_volume% placeholder and 'Year/Vol.' shared field",
"Library: archive or unarchive an entire series in one click — 'Archive series' / 'Unarchive series' button in the series detail view; updates all books in the series via a single SQL UPDATE and recalculates sidebar counters without a page reload",
],
},
{
"title": "Bug fixes",
"type": "bugfix",
"changes": [
"TedLouis scraper: title extraction no longer includes the 'Back' button text or the author byline — only direct text nodes of the title heading are used",
],
},
],
},
{
"version": "v0.1.9",
"date": "2026-04-08",
"summary": "Five new scrapers (Nifty, codeysworld.org, iomfats.org, tedlouis.com), break image settings, and bug fixes.",
"sections": [
{
"title": "New features",
"type": "feature",
"changes": [
"New scraper: Nifty.org (classic) — scrapes plain-text email-format stories; email headers stripped, boilerplate paragraphs auto-detected and hidden, scene-break patterns converted to break images",
"New scraper: new.nifty.org — scrapes the Next.js version of Nifty; reads chapter content from RSC payload when the static HTML does not include it; boilerplate detection shared with classic Nifty",
"New scraper: codeysworld.org — single-file and multi-chapter stories; title and author extracted from heading elements; category from URL path stored as tag; navigation links and audio links stripped from chapter content",
"New scraper: iomfats.org — all stories are listed on a single author page; provide any chapter URL and the scraper finds the correct story automatically; supports single stories and multi-part series (series name, book title, and series index derived from the page structure)",
"New scraper: tedlouis.com — all pages use opaque token-based routing (?t=TOKEN); provide the story index URL and the scraper collects all chapter links from the three-column chapter list",
"Settings: break image upload — upload a custom PNG/JPG/WebP to use as the scene break image in all converted books; stored in the imagestore and applied to both DB-stored and EPUB-format books",
"Settings: develop mode toggle — shows a DEVELOP banner and updates the page title across all pages when enabled",
],
},
{
"title": "Bug fixes",
"type": "bugfix",
"changes": [
"Break images were not displayed in DB-stored books — the image path '../Images/break.png' is a relative EPUB path that does not exist for DB content; DB mode now uses '/static/break.png'",
"Break images were silently lost during import — the image was decomposed before element_to_xhtml ran, leaving an empty wrapper; the wrapper is now replaced with <hr> so the break is correctly rendered",
],
},
],
},
{
"version": "v0.1.8",
"date": "2026-04-06",
"summary": "Cover upload for DB-stored books, and rating moved to the Edit metadata panel.",
"sections": [
{
"title": "Bug fixes",
"type": "bugfix",
"changes": [
"Library: cover upload now works for DB-stored books — the upload endpoint previously returned 'File not found' because DB books have no file on disk; the cover is now stored directly in the cover cache",
],
},
{
"title": "Improvements",
"type": "improvement",
"changes": [
"Book detail: rating moved from clickable stars to a dropdown in the Edit metadata panel — avoids touch-input issues on iPad where hover state caused all stars to appear filled",
],
},
],
},
{
"version": "v0.1.7",
"date": "2026-04-06",
"summary": "Search filter for unread novels/shorts, Dropbox chunked upload fix, and underscores in new filenames.",
"sections": [
{
"title": "New feature",
"type": "feature",
"changes": [
"Search: filter on unread novels or unread shorts — a second toggle row (All / Unread novels / Unread shorts) restricts results to books with no reading history; filter is preserved in the URL",
],
},
{
"title": "Bug fixes",
"type": "bugfix",
"changes": [
"Backup: files larger than 148 MB now upload correctly — chunked upload session (100 MB per chunk) replaces the single-call upload that hit Dropbox's payload size limit",
],
},
{
"title": "Improvements",
"type": "improvement",
"changes": [
"File paths: spaces in new filenames are now replaced with underscores (publisher, author, title, series segments); series separator changed from ' - ' to '_-_'",
],
},
],
},
{
"version": "v0.1.6",
"date": "2026-04-05",
"summary": "Bug fixes: double chapter titles in exported EPUBs, and authors/publishers with only archived books now remain visible.",
"sections": [
{
"title": "Bug fixes",
"type": "bugfix",
"changes": [
"Export EPUB: double chapter titles fixed — same heading-stripping logic as the reader now applied before passing content to the chapter builder",
"Library: authors and publishers with only archived books now remain visible in the Authors and Publishers list views",
],
},
],
},
{
"version": "v0.1.5",
"date": "2026-04-04",
"summary": "Bug fixes: double chapter titles in pandoc-style EPUB content, and search now requires words in order (phrase match).",
"sections": [
{
"title": "Bug fixes",
"type": "bugfix",
"changes": [
"Reader: double chapter titles for pandoc-converted books — headings wrapped in a <section> element were not stripped by the previous regex; now also removes the first heading found directly inside an opening <section> or <div>",
"Search: multi-word queries no longer match chapters where the words appear far apart — switched to phraseto_tsquery so all words must appear in order",
],
},
],
},
{
"version": "v0.1.4",
"date": "2026-04-04",
"summary": "Bug fixes: double chapter titles in the reader for DB-stored books, and archived books now shown in author/publisher detail with an indicator badge.",
"sections": [
{
"title": "Bug fixes",
"type": "bugfix",
"changes": [
"Reader: double chapter titles in DB-stored books — the chapter endpoint now strips all leading headings from stored content before prepending its own chapter title; affects books scraped before front-matter stripping was added",
"Library: archived books were missing from author and publisher detail views — detail views now include all books (active and archived); archived books have a badge on their cover so they remain distinguishable",
],
},
],
},
{ {
"version": "v0.1.3", "version": "v0.1.3",
"date": "2026-04-03", "date": "2026-04-03",

View File

@ -349,33 +349,6 @@ def migrate_rebuild_chapter_tsv_with_title() -> None:
) )
def migrate_create_app_settings() -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
develop_mode BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT single_row CHECK (id = 1)
)
"""
)
_exec("INSERT INTO app_settings (id, develop_mode) VALUES (1, FALSE) ON CONFLICT DO NOTHING")
def migrate_app_settings_break_image() -> None:
_exec("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS break_image_sha256 VARCHAR(64) DEFAULT NULL")
_exec("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS break_image_ext VARCHAR(10) DEFAULT NULL")
def migrate_series_volume() -> None:
_exec(
"""
ALTER TABLE library
ADD COLUMN IF NOT EXISTS series_volume VARCHAR(20) NOT NULL DEFAULT ''
"""
)
def run_migrations() -> None: def run_migrations() -> None:
migrate_create_library() migrate_create_library()
migrate_create_book_tags() migrate_create_book_tags()
@ -398,6 +371,3 @@ def run_migrations() -> None:
migrate_create_book_images() migrate_create_book_images()
migrate_create_book_chapters() migrate_create_book_chapters()
migrate_rebuild_chapter_tsv_with_title() migrate_rebuild_chapter_tsv_with_title()
migrate_create_app_settings()
migrate_app_settings_break_image()
migrate_series_volume()

View File

@ -14,12 +14,13 @@ import httpx
from dropbox.exceptions import ApiError, AuthError from dropbox.exceptions import ApiError, AuthError
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from shared_templates import templates from fastapi.templating import Jinja2Templates
from db import get_db_conn from db import get_db_conn
from routers.common import scan_media, upsert_book from routers.common import scan_media, upsert_book
from security import decrypt_value, encrypt_value, is_encrypted_value from security import decrypt_value, encrypt_value, is_encrypted_value
templates = Jinja2Templates(directory="templates")
router = APIRouter() router = APIRouter()
LIBRARY_DIR = Path(os.environ.get("LIBRARY_DIR", "library")) LIBRARY_DIR = Path(os.environ.get("LIBRARY_DIR", "library"))
@ -434,36 +435,12 @@ def _ensure_dropbox_dir(client: dropbox.Dropbox, path: str) -> None:
pass pass
_DROPBOX_UPLOAD_CHUNK = 100 * 1024 * 1024 # 100 MB — below the 150 MB files_upload limit
_DROPBOX_UPLOAD_THRESHOLD = 148 * 1024 * 1024 # use session upload above this size
def _dropbox_upload_bytes(client: dropbox.Dropbox, target_path: str, data: bytes) -> int: def _dropbox_upload_bytes(client: dropbox.Dropbox, target_path: str, data: bytes) -> int:
parent = str(Path(target_path).parent).replace("\\", "/") parent = str(Path(target_path).parent).replace("\\", "/")
if not parent.startswith("/"): if not parent.startswith("/"):
parent = "/" + parent parent = "/" + parent
_ensure_dropbox_dir(client, parent) _ensure_dropbox_dir(client, parent)
mode = dropbox.files.WriteMode.overwrite client.files_upload(data, target_path, mode=dropbox.files.WriteMode.overwrite, mute=True)
if len(data) <= _DROPBOX_UPLOAD_THRESHOLD:
client.files_upload(data, target_path, mode=mode, mute=True)
else:
# Chunked upload session for large files
offset = 0
session_id = None
while offset < len(data):
chunk = data[offset : offset + _DROPBOX_UPLOAD_CHUNK]
if session_id is None:
res = client.files_upload_session_start(chunk)
session_id = res.session_id
else:
cursor = dropbox.files.UploadSessionCursor(session_id=session_id, offset=offset)
remaining = len(data) - offset - len(chunk)
if remaining == 0:
commit = dropbox.files.CommitInfo(path=target_path, mode=mode, mute=True)
client.files_upload_session_finish(chunk, cursor, commit)
else:
client.files_upload_session_append_v2(chunk, cursor)
offset += len(chunk)
return len(data) return len(data)

View File

@ -5,7 +5,7 @@ from pathlib import Path
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from shared_templates import templates from fastapi.templating import Jinja2Templates
from db import get_db_conn from db import get_db_conn
from epub import build_epub from epub import build_epub
@ -13,6 +13,7 @@ from routers.common import LIBRARY_DIR, make_rel_path, upsert_book
from xhtml import normalize_wysiwyg_html from xhtml import normalize_wysiwyg_html
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates")
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────

View File

@ -4,7 +4,7 @@ from pathlib import Path
from fastapi import APIRouter, File, Form, Request, UploadFile from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from shared_templates import templates from fastapi.templating import Jinja2Templates
from cbr import cbr_page_count from cbr import cbr_page_count
from db import get_db_conn from db import get_db_conn
@ -18,6 +18,7 @@ from routers.common import (
upsert_book, upsert_book,
) )
templates = Jinja2Templates(directory="templates")
router = APIRouter() router = APIRouter()
@ -72,7 +73,6 @@ async def library_bulk_import(
author = (row.get("author") or "").strip() or shared_author author = (row.get("author") or "").strip() or shared_author
publisher = (row.get("publisher") or "").strip() or shared_publisher publisher = (row.get("publisher") or "").strip() or shared_publisher
series = (row.get("series") or "").strip() or shared_data.get("series", "") series = (row.get("series") or "").strip() or shared_data.get("series", "")
series_volume = ((row.get("series_volume") or "").strip() or shared_data.get("series_volume", ""))[:20]
series_index, series_suffix = parse_volume_str(row.get("volume") or "") series_index, series_suffix = parse_volume_str(row.get("volume") or "")
status = (row.get("status") or "").strip() or shared_status status = (row.get("status") or "").strip() or shared_status
@ -121,7 +121,6 @@ async def library_bulk_import(
"series": series, "series": series,
"series_index": series_index, "series_index": series_index,
"series_suffix": series_suffix, "series_suffix": series_suffix,
"series_volume": series_volume if series else "",
"publication_status": status, "publication_status": status,
"publish_date": publish_date, "publish_date": publish_date,
"has_cover": has_cover, "has_cover": has_cover,

View File

@ -1,10 +1,11 @@
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from shared_templates import templates from fastapi.templating import Jinja2Templates
from changelog import CHANGELOG from changelog import CHANGELOG
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/changelog", response_class=HTMLResponse) @router.get("/changelog", response_class=HTMLResponse)

View File

@ -30,9 +30,9 @@ def is_db_filename(filename: str) -> bool:
def clean_segment(value: str, fallback: str, max_len: int) -> str: def clean_segment(value: str, fallback: str, max_len: int) -> str:
txt = re.sub(r"\s+", "_", (value or "").strip()) txt = re.sub(r"\s+", " ", (value or "").strip())
txt = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", txt) txt = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", txt)
txt = re.sub(r"\.+$", "", txt).strip("_") txt = re.sub(r"\.+$", "", txt).strip()
return (txt or fallback)[:max_len] return (txt or fallback)[:max_len]
@ -93,7 +93,7 @@ def make_rel_path(*, media_type: str, publisher: str, author: str, title: str, s
if series_name: if series_name:
idx = coerce_series_index(series_index) idx = coerce_series_index(series_index)
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5] 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 / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}"
return Path("db") / pub / auth / ttl return Path("db") / pub / auth / ttl
if media_type == "epub": if media_type == "epub":
@ -104,7 +104,7 @@ def make_rel_path(*, media_type: str, publisher: str, author: str, title: str, s
if series_name: if series_name:
idx = coerce_series_index(series_index) idx = coerce_series_index(series_index)
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5] 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 / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}.epub"
return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub" return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub"
if media_type == "pdf": if media_type == "pdf":
@ -122,7 +122,7 @@ def make_rel_path(*, media_type: str, publisher: str, author: str, title: str, s
if series_name: if series_name:
idx = coerce_series_index(series_index) idx = coerce_series_index(series_index)
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5] 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 / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}{comics_ext}"
return Path("comics") / pub / auth / f"{ttl}{comics_ext}" return Path("comics") / pub / auth / f"{ttl}{comics_ext}"
@ -239,7 +239,6 @@ def scan_epub(path: Path) -> dict:
"series": "", "series": "",
"series_index": 0, "series_index": 0,
"series_suffix": "", "series_suffix": "",
"series_volume": "",
"title": "", "title": "",
"publication_status": "", "publication_status": "",
"author": "", "author": "",
@ -281,9 +280,6 @@ def scan_epub(path: Path) -> dict:
m = re.search(r'<meta[^>]*name="novela:series_suffix"[^>]*content="([^"]+)"', opf, re.IGNORECASE) m = re.search(r'<meta[^>]*name="novela:series_suffix"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
if m: if m:
out["series_suffix"] = re.sub(r"[^a-z]", "", m.group(1).lower())[:5] out["series_suffix"] = re.sub(r"[^a-z]", "", m.group(1).lower())[:5]
m = re.search(r'<meta[^>]*name="novela:series_volume"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
if m:
out["series_volume"] = _html.unescape(m.group(1).strip())[:20]
m = re.search(r'<meta[^>]*name="publication_status"[^>]*content="([^"]+)"', opf, re.IGNORECASE) m = re.search(r'<meta[^>]*name="publication_status"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
if m: if m:
out["publication_status"] = _html.unescape(m.group(1).strip()) out["publication_status"] = _html.unescape(m.group(1).strip())
@ -362,9 +358,9 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N
cur.execute( cur.execute(
""" """
INSERT INTO library (filename, media_type, storage_type, title, author, publisher, has_cover, INSERT INTO library (filename, media_type, storage_type, title, author, publisher, has_cover,
series, series_index, series_suffix, series_volume, publication_status, source_url, series, series_index, series_suffix, publication_status, source_url,
publish_date, description, needs_review, want_to_read, rating, updated_at) publish_date, description, needs_review, want_to_read, rating, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, %s, NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, %s, NOW())
ON CONFLICT (filename) DO UPDATE SET ON CONFLICT (filename) DO UPDATE SET
media_type = EXCLUDED.media_type, media_type = EXCLUDED.media_type,
storage_type = EXCLUDED.storage_type, storage_type = EXCLUDED.storage_type,
@ -375,7 +371,6 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N
series = COALESCE(NULLIF(EXCLUDED.series, ''), library.series), series = COALESCE(NULLIF(EXCLUDED.series, ''), library.series),
series_index = CASE WHEN COALESCE(EXCLUDED.series_index, 0) > 0 THEN EXCLUDED.series_index ELSE library.series_index END, series_index = CASE WHEN COALESCE(EXCLUDED.series_index, 0) > 0 THEN EXCLUDED.series_index ELSE library.series_index END,
series_suffix = COALESCE(NULLIF(EXCLUDED.series_suffix, ''), library.series_suffix), series_suffix = COALESCE(NULLIF(EXCLUDED.series_suffix, ''), library.series_suffix),
series_volume = COALESCE(NULLIF(EXCLUDED.series_volume, ''), library.series_volume),
publication_status = COALESCE(NULLIF(EXCLUDED.publication_status, ''), library.publication_status), publication_status = COALESCE(NULLIF(EXCLUDED.publication_status, ''), library.publication_status),
source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), library.source_url), source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), library.source_url),
publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date), publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date),
@ -394,7 +389,6 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N
meta.get("series", ""), meta.get("series", ""),
meta.get("series_index", 0), meta.get("series_index", 0),
meta.get("series_suffix", ""), meta.get("series_suffix", ""),
meta.get("series_volume", ""),
meta.get("publication_status", ""), meta.get("publication_status", ""),
meta.get("source_url", ""), meta.get("source_url", ""),
meta.get("publish_date") or None, meta.get("publish_date") or None,
@ -440,7 +434,6 @@ def list_library_json() -> list[dict]:
l.rating, l.rating,
COALESCE(l.series_suffix, '') AS series_suffix, COALESCE(l.series_suffix, '') AS series_suffix,
COALESCE(l.storage_type, 'file') AS storage_type, COALESCE(l.storage_type, 'file') AS storage_type,
COALESCE(l.series_volume, '') AS series_volume,
json_agg( json_agg(
json_build_object('tag', bt.tag, 'tag_type', bt.tag_type) json_build_object('tag', bt.tag, 'tag_type', bt.tag_type)
) FILTER (WHERE bt.tag IS NOT NULL) AS tags ) FILTER (WHERE bt.tag IS NOT NULL) AS tags
@ -458,8 +451,8 @@ def list_library_json() -> list[dict]:
l.archived, l.needs_review, l.updated_at, l.archived, l.needs_review, l.updated_at,
rp.progress, rp.cfi, rp.page, rp.progress, rp.cfi, rp.page,
rs.read_count, rs.last_read, rs.read_count, rs.last_read,
cc.filename, l.rating, l.series_suffix, l.storage_type, l.series_volume cc.filename, l.rating, l.series_suffix, l.storage_type
ORDER BY COALESCE(l.publisher, ''), COALESCE(l.author, ''), COALESCE(l.series, ''), COALESCE(l.series_volume, ''), l.series_index, COALESCE(l.title, '') ORDER BY COALESCE(l.publisher, ''), COALESCE(l.author, ''), COALESCE(l.series, ''), l.series_index, COALESCE(l.title, '')
""" """
) )
rows = cur.fetchall() rows = cur.fetchall()
@ -478,7 +471,6 @@ def list_library_json() -> list[dict]:
"series": r[6] or "", "series": r[6] or "",
"series_index": r[7] or 0, "series_index": r[7] or 0,
"series_suffix": r[20] or "", "series_suffix": r[20] or "",
"series_volume": r[22] or "",
"publication_status": r[8] or "", "publication_status": r[8] or "",
"want_to_read": bool(r[9]), "want_to_read": bool(r[9]),
"archived": bool(r[10]), "archived": bool(r[10]),
@ -490,7 +482,7 @@ def list_library_json() -> list[dict]:
"read_count": r[16] or 0, "read_count": r[16] or 0,
"last_read": r[17].isoformat() if r[17] else None, "last_read": r[17].isoformat() if r[17] else None,
"storage_type": r[21] or "file", "storage_type": r[21] or "file",
"tags": r[23] or [], "tags": r[22] or [],
"rating": r[19] or 0, "rating": r[19] or 0,
} }
) )

View File

@ -8,13 +8,14 @@ from pathlib import Path
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.responses import HTMLResponse, JSONResponse, Response
from shared_templates import templates from fastapi.templating import Jinja2Templates
from db import get_db_conn from db import get_db_conn
from epub import read_epub_file, write_epub_file from epub import read_epub_file, write_epub_file
from routers.common import LIBRARY_DIR, is_db_filename, resolve_library_path, upsert_chapter from routers.common import LIBRARY_DIR, is_db_filename, resolve_library_path, upsert_chapter
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates")
def _norm(base_dir: str, rel: str) -> str: def _norm(base_dir: str, rel: str) -> str:

View File

@ -2,10 +2,11 @@ from urllib.parse import unquote
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from shared_templates import templates from fastapi.templating import Jinja2Templates
from db import get_db_conn from db import get_db_conn
templates = Jinja2Templates(directory="templates")
router = APIRouter() router = APIRouter()

View File

@ -8,10 +8,10 @@ from typing import AsyncGenerator
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
import httpx import httpx
from bs4 import BeautifulSoup, NavigableString, Tag from bs4 import Tag
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.responses import HTMLResponse, StreamingResponse
from shared_templates import templates from fastapi.templating import Jinja2Templates
from db import get_db_conn from db import get_db_conn
from epub import detect_image_format, make_chapter_xhtml, make_epub from epub import detect_image_format, make_chapter_xhtml, make_epub
@ -33,6 +33,7 @@ from scrapers.base import HEADERS
from security import decrypt_value, encrypt_value, is_encrypted_value from security import decrypt_value, encrypt_value, is_encrypted_value
from xhtml import configure_break_patterns, element_to_xhtml, is_break_element from xhtml import configure_break_patterns, element_to_xhtml, is_break_element
templates = Jinja2Templates(directory="templates")
router = APIRouter() router = APIRouter()
JOBS: dict[str, dict] = {} JOBS: dict[str, dict] = {}
@ -400,15 +401,6 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
_load_break_patterns() _load_break_patterns()
storage_mode = job.get("storage_mode", "db")
# Break image path depends on storage mode:
# - EPUB: relative path inside the EPUB ZIP (break.png is embedded)
# - DB: absolute URL served by the static files handler
if storage_mode == "epub":
break_img_path = "../Images/break.png"
else:
break_img_path = "/static/break.png"
# Collect chapters as {title, content_html, images: [(sha256, ext, media_type, size, data)]} # Collect chapters as {title, content_html, images: [(sha256, ext, media_type, size, data)]}
chapters = [] chapters = []
for i, ch in enumerate(book["chapters"], 1): for i, ch in enumerate(book["chapters"], 1):
@ -421,16 +413,6 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
if content_el: if content_el:
for img_tag in content_el.find_all("img"): for img_tag in content_el.find_all("img"):
if is_break_element(img_tag): if is_break_element(img_tag):
# Replace the parent with <hr> if it contains only
# this image, so element_to_xhtml can detect the break.
parent = img_tag.parent
meaningful = [
c for c in parent.children
if not (isinstance(c, NavigableString) and not c.strip())
]
if len(meaningful) == 1 and parent is not content_el:
parent.replace_with(BeautifulSoup("<hr/>", "html.parser").hr)
else:
img_tag.decompose() img_tag.decompose()
continue continue
src = img_tag.get("src", "") src = img_tag.get("src", "")
@ -467,7 +449,7 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
filled_p = len(all_p) - empty_p filled_p = len(all_p) - empty_p
empty_p_is_spacer = filled_p > 0 and empty_p >= filled_p * 0.5 empty_p_is_spacer = filled_p > 0 and empty_p >= filled_p * 0.5
for child in content_el.children: for child in content_el.children:
part = element_to_xhtml(child, break_img_path=break_img_path, empty_p_is_spacer=empty_p_is_spacer) part = element_to_xhtml(child, empty_p_is_spacer=empty_p_is_spacer)
if part.strip(): if part.strip():
xhtml_parts.append(part) xhtml_parts.append(part)
@ -482,6 +464,7 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
job["done"] = True job["done"] = True
return return
storage_mode = job.get("storage_mode", "db")
send("status", {"message": "Saving to library..."}) send("status", {"message": "Saving to library..."})
book_tags = ( book_tags = (

View File

@ -6,7 +6,7 @@ from pathlib import Path
from fastapi import APIRouter, File, Request, UploadFile from fastapi import APIRouter, File, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from shared_templates import templates from fastapi.templating import Jinja2Templates
from PIL import UnidentifiedImageError from PIL import UnidentifiedImageError
from db import get_db_conn from db import get_db_conn
@ -28,6 +28,7 @@ from routers.common import (
upsert_cover_cache, upsert_cover_cache,
) )
templates = Jinja2Templates(directory="templates")
router = APIRouter() router = APIRouter()
@ -342,6 +343,12 @@ async def library_cover(filename: str):
@router.post("/library/cover/{filename:path}") @router.post("/library/cover/{filename:path}")
async def library_add_cover(filename: str, request: Request): async def library_add_cover(filename: str, request: Request):
full = resolve_library_path(filename)
if full is None or not full.exists():
return {"error": "File not found"}
if media_type_from_suffix(full) != "epub":
return {"error": "Cover upload is only supported for EPUB"}
body = await request.json() body = await request.json()
cover_b64 = body.get("cover_b64", "") cover_b64 = body.get("cover_b64", "")
if not cover_b64: if not cover_b64:
@ -349,35 +356,6 @@ async def library_add_cover(filename: str, request: Request):
try: try:
cover_data = base64.b64decode(cover_b64) cover_data = base64.b64decode(cover_b64)
except Exception as e:
return {"error": str(e)}
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(
"UPDATE library SET has_cover = TRUE, updated_at = NOW() WHERE filename = %s",
(filename,),
)
try:
thumb = make_cover_thumb_webp(cover_data)
upsert_cover_cache(conn, filename, "image/webp", thumb)
except (UnidentifiedImageError, OSError, ValueError):
pass
return {"ok": True}
full = resolve_library_path(filename)
if full is None or not full.exists():
return {"error": "File not found"}
if media_type_from_suffix(full) != "epub":
return {"error": "Cover upload is only supported for EPUB"}
try:
add_cover_to_epub(full, cover_data) add_cover_to_epub(full, cover_data)
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
@ -441,24 +419,6 @@ async def library_archive(filename: str):
return {"ok": True, "archived": val} return {"ok": True, "archived": val}
@router.post("/library/archive-series")
async def library_archive_series(request: Request):
body = await request.json()
series = body.get("series", "")
archive = bool(body.get("archive", True))
if not series:
return {"error": "series is required"}
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE library SET archived = %s, updated_at = NOW() WHERE series = %s",
(archive, series),
)
count = cur.rowcount
return {"ok": True, "archived": archive, "count": count}
@router.post("/library/new/mark-reviewed") @router.post("/library/new/mark-reviewed")
async def library_mark_new_reviewed(request: Request): async def library_mark_new_reviewed(request: Request):
body = await request.json() body = await request.json()
@ -703,59 +663,46 @@ async def bulk_check_duplicates(request: Request):
for item in items: for item in items:
title = item.get("title", "").strip().lower() title = item.get("title", "").strip().lower()
author = item.get("author", "").strip().lower() author = item.get("author", "").strip().lower()
series = item.get("series", "").strip().lower()
vol_str = item.get("volume", "").strip() vol_str = item.get("volume", "").strip()
try: try:
vol_int = int(vol_str) if vol_str else None vol_int = int(vol_str) if vol_str else None
except ValueError: except ValueError:
vol_int = None vol_int = None
parsed.append((title, author, series, vol_int)) 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)}
with get_db_conn() as conn:
with conn.cursor() as cur:
# Check by title+author
title_author_pairs = list({(t, a) for t, a, s, _ in parsed if t})
existing_with_vol: set = set()
existing_title_author: set = set()
if title_author_pairs:
conditions = " OR ".join( conditions = " OR ".join(
"(LOWER(TRIM(title)) = %s AND LOWER(TRIM(author)) = %s)" for _ in title_author_pairs "(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] 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( cur.execute(
f"SELECT LOWER(TRIM(title)), LOWER(TRIM(author)), series_index" f"SELECT LOWER(TRIM(title)), LOWER(TRIM(author)), series_index"
f" FROM library WHERE {conditions}", f" FROM library WHERE {conditions}",
params, params,
) )
rows = cur.fetchall() 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} 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} existing_title_author = {(r[0] or "", r[1] or "") for r in rows}
# Check by series+author+series_index (catches title-format changes)
series_author_pairs = list({(s, a) for _, a, s, _ in parsed if s and a})
existing_series_vol: set = set()
if series_author_pairs:
conditions2 = " OR ".join(
"(LOWER(TRIM(series)) = %s AND LOWER(TRIM(author)) = %s)" for _ in series_author_pairs
)
params2 = [v for pair in series_author_pairs for v in pair]
cur.execute(
f"SELECT LOWER(TRIM(series)), LOWER(TRIM(author)), series_index"
f" FROM library WHERE {conditions2}",
params2,
)
existing_series_vol = {(r[0] or "", r[1] or "", r[2]) for r in cur.fetchall()}
duplicates = [] duplicates = []
for title, author, series, vol_int in parsed: for title, author, vol_int in parsed:
if vol_int is not None: if not title:
by_title = (title, author, vol_int) in existing_with_vol if title else False
by_series = (series, author, vol_int) in existing_series_vol if series else False
duplicates.append(by_title or by_series)
elif title:
duplicates.append((title, author) in existing_title_author)
else:
duplicates.append(False) 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} return {"duplicates": duplicates}

View File

@ -12,7 +12,7 @@ from pathlib import Path
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from shared_templates import templates from fastapi.templating import Jinja2Templates
from cbr import cbr_get_page, cbr_page_count from cbr import cbr_get_page, cbr_page_count
from db import get_db_conn from db import get_db_conn
@ -34,6 +34,7 @@ from routers.common import (
) )
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# EPUB helpers # EPUB helpers
@ -280,7 +281,6 @@ def _sync_epub_metadata(
series: str, series: str,
series_index: int | str | None, series_index: int | str | None,
series_suffix: str = "", series_suffix: str = "",
series_volume: str = "",
subjects: list[str], subjects: list[str],
) -> None: ) -> None:
"""Write edited metadata back into OPF so DB and EPUB stay aligned.""" """Write edited metadata back into OPF so DB and EPUB stay aligned."""
@ -360,11 +360,9 @@ def _sync_epub_metadata(
set_named_meta('calibre:series_index', str(_coerce_series_index(series_index))) set_named_meta('calibre:series_index', str(_coerce_series_index(series_index)))
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5] sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5]
set_named_meta('novela:series_suffix', sfx) set_named_meta('novela:series_suffix', sfx)
set_named_meta('novela:series_volume', (series_volume or '').strip()[:20])
else: else:
set_named_meta('calibre:series_index', '') set_named_meta('calibre:series_index', '')
set_named_meta('novela:series_suffix', '') set_named_meta('novela:series_suffix', '')
set_named_meta('novela:series_volume', '')
_rewrite_epub_entries(epub_path, {opf_path: str(opf).encode('utf-8')}) _rewrite_epub_entries(epub_path, {opf_path: str(opf).encode('utf-8')})
@ -400,9 +398,9 @@ def _rewrite_epub_entries(epub_path: Path, updates: dict[str, bytes], remove_pat
def _clean_segment(value: str, fallback: str, max_len: int = 100) -> str: def _clean_segment(value: str, fallback: str, max_len: int = 100) -> str:
txt = re.sub(r"\s+", "_", (value or "").strip()) txt = re.sub(r"\s+", " ", (value or "").strip())
txt = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", txt) txt = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", txt)
txt = re.sub(r"\.+$", "", txt).strip("_") txt = re.sub(r"\.+$", "", txt).strip()
if not txt: if not txt:
txt = fallback txt = fallback
return txt[:max_len] return txt[:max_len]
@ -434,7 +432,7 @@ def _make_rel_path(
if series_name: if series_name:
idx = _coerce_series_index(series_index) idx = _coerce_series_index(series_index)
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5] 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 / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}.epub"
return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub" return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub"
if ext == ".pdf": if ext == ".pdf":
@ -447,7 +445,7 @@ def _make_rel_path(
if series_name: if series_name:
idx = _coerce_series_index(series_index) idx = _coerce_series_index(series_index)
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5] sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5]
return Path("comics") / pub / auth / "Series" / series_name / f"{idx:03d}{sfx}_-_{ttl}{ext}" return Path("comics") / pub / auth / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}{ext}"
return Path("comics") / pub / auth / f"{ttl}{ext}" return Path("comics") / pub / auth / f"{ttl}{ext}"
@ -537,14 +535,6 @@ async def get_chapter_html(filename: str, index: int):
return Response(status_code=404) return Response(status_code=404)
title, content = row title, content = row
safe_title = _html.escape(title or "") safe_title = _html.escape(title or "")
# Strip leading h-tags from stored content — the endpoint always
# prepends its own <h2 class="chapter-title">, so content scraped
# before front-matter stripping was added would show the title twice.
# Handles two layouts:
# 1. <h1>…</h1> at the very start of content
# 2. <section …>\n<h1>…</h1> (pandoc-style wrapping)
content = re.sub(r'(?si)^(\s*<h[1-4](?:\s[^>]*)?>.*?</h[1-4]>)+\s*', '', content)
content = re.sub(r'(?si)(<(?:section|div)[^>]*>\s*)<h[1-4][^>]*>.*?</h[1-4]>\s*', r'\1', content, count=1)
return Response( return Response(
f'<body><h2 class="chapter-title">{safe_title}</h2>\n{content}\n</body>', f'<body><h2 class="chapter-title">{safe_title}</h2>\n{content}\n</body>',
media_type="text/html", media_type="text/html",
@ -723,8 +713,7 @@ async def book_detail_page(filename: str, request: Request):
SELECT title, author, publisher, has_cover, series, series_index, SELECT title, author, publisher, has_cover, series, series_index,
publication_status, want_to_read, source_url, archived, publish_date, description, publication_status, want_to_read, source_url, archived, publish_date, description,
rating, COALESCE(series_suffix, '') AS series_suffix, rating, COALESCE(series_suffix, '') AS series_suffix,
COALESCE(storage_type, 'file') AS storage_type, COALESCE(storage_type, 'file') AS storage_type
COALESCE(series_volume, '') AS series_volume
FROM library WHERE filename = %s FROM library WHERE filename = %s
""", """,
(filename,), (filename,),
@ -739,7 +728,6 @@ async def book_detail_page(filename: str, request: Request):
"series": lib_row[4] or "", "series": lib_row[4] or "",
"series_index": lib_row[5] or 0, "series_index": lib_row[5] or 0,
"series_suffix": lib_row[13] or "", "series_suffix": lib_row[13] or "",
"series_volume": lib_row[15] or "",
"publication_status": lib_row[6] or "", "publication_status": lib_row[6] or "",
"want_to_read": lib_row[7] or False, "want_to_read": lib_row[7] or False,
"source_url": lib_row[8] or "", "source_url": lib_row[8] or "",
@ -826,7 +814,6 @@ async def book_detail_page(filename: str, request: Request):
"series": entry["series"], "series": entry["series"],
"series_index": entry["series_index"], "series_index": entry["series_index"],
"series_suffix": entry["series_suffix"], "series_suffix": entry["series_suffix"],
"series_volume": entry.get("series_volume", ""),
"genres": genres, "genres": genres,
"subgenres": subgenres, "subgenres": subgenres,
"tags": tags_list, "tags": tags_list,
@ -886,53 +873,6 @@ async def api_suggestions(type: str | None = None):
return JSONResponse([r[0] for r in cur.fetchall()]) return JSONResponse([r[0] for r in cur.fetchall()])
@router.get("/api/series-nav/{filename:path}")
async def api_series_nav(filename: str):
"""Return the previous and next book in the same series, ordered by series_index."""
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT series, series_index, COALESCE(series_suffix, '') FROM library WHERE filename = %s",
(filename,),
)
row = cur.fetchone()
if not row or not row[0]:
return JSONResponse({"prev": None, "next": None})
series, current_index, current_suffix = row
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT filename, title, series_index, COALESCE(series_suffix, '')
FROM library
WHERE series = %s AND series IS NOT NULL AND series <> ''
ORDER BY series_index ASC, series_suffix ASC
""",
(series,),
)
siblings = cur.fetchall()
# Find position of current book in ordered list
pos = None
for i, (fn, _title, idx, sfx) in enumerate(siblings):
if fn == filename:
pos = i
break
if pos is None:
return JSONResponse({"prev": None, "next": None})
def entry(row):
return {"filename": row[0], "title": row[1], "index": row[2], "suffix": row[3]}
return JSONResponse({
"prev": entry(siblings[pos - 1]) if pos > 0 else None,
"next": entry(siblings[pos + 1]) if pos < len(siblings) - 1 else None,
})
@router.patch("/library/book/{filename:path}") @router.patch("/library/book/{filename:path}")
async def book_update(filename: str, request: Request): async def book_update(filename: str, request: Request):
"""Update book metadata and tags, and rename/move the file when needed.""" """Update book metadata and tags, and rename/move the file when needed."""
@ -941,7 +881,6 @@ async def book_update(filename: str, request: Request):
author = body.get("author", "") author = body.get("author", "")
publisher = body.get("publisher", "") publisher = body.get("publisher", "")
series = body.get("series", "") series = body.get("series", "")
series_volume = (body.get("series_volume", "") or "").strip()[:20]
from routers.common import parse_volume_str from routers.common import parse_volume_str
series_index, series_suffix = parse_volume_str(body.get("series_index", "")) series_index, series_suffix = parse_volume_str(body.get("series_index", ""))
@ -971,11 +910,11 @@ async def book_update(filename: str, request: Request):
""" """
INSERT INTO library ( INSERT INTO library (
filename, title, author, publisher, has_cover, filename, title, author, publisher, has_cover,
series, series_index, series_suffix, series_volume, publication_status, series, series_index, series_suffix, publication_status,
source_url, publish_date, description, source_url, publish_date, description,
archived, needs_review, storage_type, updated_at archived, needs_review, storage_type, updated_at
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, FALSE, 'db', NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, FALSE, 'db', NOW())
ON CONFLICT (filename) DO UPDATE SET ON CONFLICT (filename) DO UPDATE SET
title = EXCLUDED.title, title = EXCLUDED.title,
author = EXCLUDED.author, author = EXCLUDED.author,
@ -983,7 +922,6 @@ async def book_update(filename: str, request: Request):
series = EXCLUDED.series, series = EXCLUDED.series,
series_index = EXCLUDED.series_index, series_index = EXCLUDED.series_index,
series_suffix = EXCLUDED.series_suffix, series_suffix = EXCLUDED.series_suffix,
series_volume = EXCLUDED.series_volume,
publication_status = EXCLUDED.publication_status, publication_status = EXCLUDED.publication_status,
source_url = EXCLUDED.source_url, source_url = EXCLUDED.source_url,
publish_date = EXCLUDED.publish_date, publish_date = EXCLUDED.publish_date,
@ -995,7 +933,6 @@ async def book_update(filename: str, request: Request):
new_filename, title, author, publisher, has_cover, new_filename, title, author, publisher, has_cover,
series, series_index if series else 0, series, series_index if series else 0,
series_suffix if series else "", series_suffix if series else "",
series_volume if series else "",
body.get("publication_status", ""), body.get("publication_status", ""),
body.get("source_url", ""), body.get("source_url", ""),
body.get("publish_date") or None, body.get("publish_date") or None,
@ -1064,7 +1001,6 @@ async def book_update(filename: str, request: Request):
series=series, series=series,
series_index=series_index if series else 0, series_index=series_index if series else 0,
series_suffix=series_suffix if series else "", series_suffix=series_suffix if series else "",
series_volume=series_volume if series else "",
subjects=(body.get("genres", []) + body.get("subgenres", []) + body.get("tags", [])), subjects=(body.get("genres", []) + body.get("subgenres", []) + body.get("tags", [])),
) )
@ -1079,11 +1015,11 @@ async def book_update(filename: str, request: Request):
""" """
INSERT INTO library ( INSERT INTO library (
filename, title, author, publisher, has_cover, filename, title, author, publisher, has_cover,
series, series_index, series_suffix, series_volume, publication_status, series, series_index, series_suffix, publication_status,
source_url, publish_date, description, source_url, publish_date, description,
archived, needs_review, updated_at archived, needs_review, updated_at
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, FALSE, NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, FALSE, NOW())
ON CONFLICT (filename) DO UPDATE SET ON CONFLICT (filename) DO UPDATE SET
title = EXCLUDED.title, title = EXCLUDED.title,
author = EXCLUDED.author, author = EXCLUDED.author,
@ -1091,7 +1027,6 @@ async def book_update(filename: str, request: Request):
series = EXCLUDED.series, series = EXCLUDED.series,
series_index = EXCLUDED.series_index, series_index = EXCLUDED.series_index,
series_suffix = EXCLUDED.series_suffix, series_suffix = EXCLUDED.series_suffix,
series_volume = EXCLUDED.series_volume,
publication_status = EXCLUDED.publication_status, publication_status = EXCLUDED.publication_status,
source_url = EXCLUDED.source_url, source_url = EXCLUDED.source_url,
publish_date = EXCLUDED.publish_date, publish_date = EXCLUDED.publish_date,
@ -1108,7 +1043,6 @@ async def book_update(filename: str, request: Request):
series, series,
series_index if series else 0, series_index if series else 0,
series_suffix if series else "", series_suffix if series else "",
series_volume if series else "",
body.get("publication_status", ""), body.get("publication_status", ""),
body.get("source_url", ""), body.get("source_url", ""),
body.get("publish_date") or None, body.get("publish_date") or None,
@ -1444,10 +1378,6 @@ async def export_epub(filename: str):
seen_images: dict[str, str] = {} seen_images: dict[str, str] = {}
chapters = [] chapters = []
for ch_idx, ch_title, ch_content in ch_rows: for ch_idx, ch_title, ch_content in ch_rows:
# Strip leading h-tags from stored content (same logic as chapter endpoint)
# to prevent double titles when make_chapter_xhtml prepends its own heading.
ch_content = re.sub(r'(?si)^(\s*<h[1-4](?:\s[^>]*)?>.*?</h[1-4]>)+\s*', '', ch_content)
ch_content = re.sub(r'(?si)(<(?:section|div)[^>]*>\s*)<h[1-4][^>]*>.*?</h[1-4]>\s*', r'\1', ch_content, count=1)
modified_html, new_imgs = _rewrite_db_images_for_epub(ch_content, seen_images) modified_html, new_imgs = _rewrite_db_images_for_epub(ch_content, seen_images)
chapter_xhtml = make_chapter_xhtml(ch_title or f"Chapter {ch_idx + 1}", modified_html, ch_idx + 1) chapter_xhtml = make_chapter_xhtml(ch_title or f"Chapter {ch_idx + 1}", modified_html, ch_idx + 1)
chapters.append({"title": ch_title or f"Chapter {ch_idx + 1}", "xhtml": chapter_xhtml, "images": new_imgs}) chapters.append({"title": ch_title or f"Chapter {ch_idx + 1}", "xhtml": chapter_xhtml, "images": new_imgs})

View File

@ -2,11 +2,12 @@
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from shared_templates import templates from fastapi.templating import Jinja2Templates
from db import get_db_conn from db import get_db_conn
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/search", response_class=HTMLResponse) @router.get("/search", response_class=HTMLResponse)
@ -15,38 +16,14 @@ async def search_page(request: Request):
@router.get("/api/search") @router.get("/api/search")
async def api_search(q: str = "", mode: str = "phrase", filter: str = "all"): async def api_search(q: str = ""):
q = q.strip() q = q.strip()
if not q or len(q) > 500: if not q or len(q) > 500:
return JSONResponse([]) return JSONResponse([])
tsquery_fn = "phraseto_tsquery" if mode == "phrase" else "plainto_tsquery"
extra_joins = ""
extra_where = ""
if filter in ("unread_novels", "unread_shorts"):
extra_joins = """
LEFT JOIN reading_sessions rs ON rs.filename = l.filename
LEFT JOIN reading_progress rp ON rp.filename = l.filename"""
extra_where = " AND rs.id IS NULL AND COALESCE(rp.progress, 0) = 0"
if filter == "unread_novels":
extra_where += """
AND NOT EXISTS (
SELECT 1 FROM book_tags bt
WHERE bt.filename = l.filename AND bt.tag = 'Shorts'
AND bt.tag_type IN ('tag', 'subject')
)"""
elif filter == "unread_shorts":
extra_where += """
AND EXISTS (
SELECT 1 FROM book_tags bt
WHERE bt.filename = l.filename AND bt.tag = 'Shorts'
AND bt.tag_type IN ('tag', 'subject')
)"""
with get_db_conn() as conn: with get_db_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
f""" """
SELECT SELECT
l.filename, l.filename,
l.title, l.title,
@ -61,13 +38,11 @@ async def api_search(q: str = "", mode: str = "phrase", filter: str = "all"):
ts_rank(bc.content_tsv, plainto_tsquery('simple', %s)) AS rank ts_rank(bc.content_tsv, plainto_tsquery('simple', %s)) AS rank
FROM book_chapters bc FROM book_chapters bc
JOIN library l ON l.filename = bc.filename JOIN library l ON l.filename = bc.filename
{extra_joins} WHERE (bc.content_tsv @@ plainto_tsquery('simple', %s)
WHERE (bc.content_tsv @@ {tsquery_fn}('simple', %s)
OR LOWER(bc.title) LIKE LOWER('%%' || %s || '%%')) OR LOWER(bc.title) LIKE LOWER('%%' || %s || '%%'))
AND NOT l.archived AND NOT l.archived
{extra_where}
GROUP BY l.filename, l.title, l.author, bc.chapter_index, bc.title, bc.content, bc.content_tsv
ORDER BY rank DESC, bc.chapter_index ASC ORDER BY rank DESC, bc.chapter_index ASC
LIMIT 30
""", """,
(q, q, q, q), (q, q, q, q),
) )

View File

@ -1,13 +1,12 @@
import re import re
from fastapi import APIRouter, Request, UploadFile, File from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from shared_templates import templates from fastapi.templating import Jinja2Templates
from db import get_db_conn from db import get_db_conn
from epub import detect_image_format
from routers.common import write_image_file
templates = Jinja2Templates(directory="templates")
router = APIRouter() router = APIRouter()
@ -96,57 +95,6 @@ async def delete_break_pattern(pid: int):
return {"ok": True} return {"ok": True}
@router.get("/api/app-settings")
async def get_app_settings():
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT develop_mode, break_image_sha256, break_image_ext FROM app_settings WHERE id = 1")
row = cur.fetchone()
if not row:
return {"develop_mode": False, "break_image_url": None}
sha, ext = row[1], row[2]
break_image_url = f"/library/db-images/{sha[:2]}/{sha}{ext}" if sha and ext else None
return {"develop_mode": bool(row[0]), "break_image_url": break_image_url}
@router.patch("/api/app-settings")
async def update_app_settings(request: Request):
body = await request.json()
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
if "develop_mode" in body:
cur.execute(
"UPDATE app_settings SET develop_mode = %s WHERE id = 1",
(bool(body["develop_mode"]),),
)
return {"ok": True}
@router.post("/api/app-settings/break-image")
async def upload_break_image(file: UploadFile = File(...)):
data = await file.read()
if not data:
return {"error": "Empty file"}
_, media_type = detect_image_format(data, file.filename or "break")
sha, ext, _ = write_image_file(data, media_type)
# Also write to static/break.png so EPUB embeds the same image
try:
with open("static/break.png", "wb") as f:
f.write(data)
except Exception:
pass
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE app_settings SET break_image_sha256 = %s, break_image_ext = %s WHERE id = 1",
(sha, ext),
)
url = f"/library/db-images/{sha[:2]}/{sha}{ext}"
return {"ok": True, "url": url}
@router.delete("/api/reading-history") @router.delete("/api/reading-history")
async def reset_reading_history(): async def reset_reading_history():
with get_db_conn() as conn: with get_db_conn() as conn:

View File

@ -1,23 +1,13 @@
from .base import BaseScraper from .base import BaseScraper
from .archiveofourown import ArchiveOfOurOwnScraper from .archiveofourown import ArchiveOfOurOwnScraper
from .awesomedude import AwesomeDudeScraper from .awesomedude import AwesomeDudeScraper
from .codeysworld import CodeysWorldScraper
from .gayauthors import GayAuthorsScraper from .gayauthors import GayAuthorsScraper
from .iomfats import IomfatsScraper
from .nifty import NiftyScraper
from .nifty_new import NiftyNewScraper
from .tedlouis import TedLouisScraper
# Register scrapers in priority order (first match wins) # Register scrapers in priority order (first match wins)
_SCRAPERS: list[type[BaseScraper]] = [ _SCRAPERS: list[type[BaseScraper]] = [
ArchiveOfOurOwnScraper, ArchiveOfOurOwnScraper,
AwesomeDudeScraper, AwesomeDudeScraper,
CodeysWorldScraper,
GayAuthorsScraper, GayAuthorsScraper,
IomfatsScraper,
NiftyNewScraper,
NiftyScraper,
TedLouisScraper,
] ]

View File

@ -1,208 +0,0 @@
import re
from urllib.parse import urljoin, urlparse
import httpx
from bs4 import BeautifulSoup
from .base import BaseScraper
CW_BASE = "https://www.codeysworld.org"
LAYOUT_RE = re.compile(
r"nav|menu|sidebar|header|footer|breadcrumb|pagination|"
r"comment|widget|aside|banner|ad|rating|follow|share",
re.I,
)
class CodeysWorldScraper(BaseScraper):
@classmethod
def matches(cls, url: str) -> bool:
return "codeysworld.org" in url
async def login(self, client: httpx.AsyncClient, username: str, password: str) -> bool:
return True # no login required
async def fetch_book_info(self, client: httpx.AsyncClient, url: str) -> dict:
r = await client.get(url)
soup = BeautifulSoup(r.text, "html.parser")
actual_url = str(r.url)
# Title: <h1>
h1 = soup.find("h1")
book_title = h1.get_text(strip=True) if h1 else "Unknown title"
# Author: <h2> "by Author Name"
author = "Unknown author"
h2 = soup.find("h2")
if h2:
text = h2.get_text(strip=True)
m = re.match(r"^by\s+(.+)$", text, re.I)
if m:
author = m.group(1).strip()
# URL path: /{author_slug}/{category}/filename.htm
tags: list[str] = []
path_parts = urlparse(actual_url).path.strip("/").split("/")
if len(path_parts) >= 3:
author_slug = path_parts[-3]
category = path_parts[-2]
elif len(path_parts) >= 2:
author_slug = path_parts[-2]
category = ""
else:
author_slug = ""
category = ""
# Fallback: derive author from URL slug if not found in page
if author == "Unknown author" and author_slug:
author = author_slug.replace("_", " ").title()
# Category → tag
if category and category.lower() not in ("codey", author_slug.lower()):
tags = [category.replace("_", " ").title()]
# Chapter discovery: links to .htm/.html in the same directory,
# excluding the index page itself and audio/image files.
base_dir = actual_url.rsplit("/", 1)[0] + "/"
chapter_links: list[dict] = []
seen: set[str] = set()
for a in soup.find_all("a", href=True):
href = a["href"]
if re.search(r"\.(mp3|mp4|ogg|wav|jpg|jpeg|png|gif)$", href, re.I):
continue
full = urljoin(actual_url, href)
if (
full.startswith(base_dir)
and re.search(r"\.html?(\?.*)?$", full, re.I)
and full.rstrip("/") != actual_url.rstrip("/")
and full not in seen
):
seen.add(full)
text = re.sub(r"\s+", " ", a.get_text(separator=" ")).strip()
chapter_links.append({"url": full, "title": text, "book_title": book_title, "author": author})
if not chapter_links:
# Single-file story
chapter_links = [{"url": actual_url, "title": book_title, "book_title": book_title, "author": author}]
chapter_method = "single_page"
else:
chapter_method = "html_scan"
for i, c in enumerate(chapter_links, 1):
t = c["title"]
if not t or t.lower() == book_title.lower():
c["title"] = f"Chapter {i}"
elif re.match(r"^\d+$", t):
c["title"] = f"Chapter {t}"
return {
"title": book_title,
"author": author,
"publisher": "codeysworld.org",
"series": "",
"series_index_hint": 0,
"genres": [],
"subgenres": [],
"tags": tags,
"description": "",
"updated_date": "",
"publication_status": "",
"source_url": url,
"chapters": chapter_links,
"chapter_method": chapter_method,
"index_image_url": None,
}
async def fetch_chapter(self, client: httpx.AsyncClient, ch: dict) -> dict:
cr = await client.get(ch["url"])
csoup = BeautifulSoup(cr.text, "html.parser")
title = ch["title"]
book_title_lc = ch.get("book_title", "").lower()
author_lc = ch.get("author", "").lower()
# Refine chapter title from an in-page heading,
# skipping the book title and "by Author" headings.
for tag in csoup.find_all(["h1", "h2", "h3"]):
text = re.sub(r"\s+", " ", tag.get_text(separator=" ")).strip()
if not text or len(text) >= 120:
continue
text_lc = text.lower()
if re.search(r"\s+by\s+", text, re.I):
continue
if book_title_lc and book_title_lc in text_lc:
continue
if author_lc and author_lc in text_lc:
continue
title = text
break
# Content extraction: prefer a content-like wrapper; fall back to body.
content_el = (
csoup.find(id=re.compile(r"^(chapter|story|content|text)[_-]?", re.I))
or csoup.find(class_=re.compile(r"story.?text|chapter.?text|post.?content|entry.?content", re.I))
or csoup.find("article")
)
if not content_el:
candidates = [
el for el in csoup.find_all(["div", "article", "section"])
if not re.search(LAYOUT_RE, " ".join(el.get("class", [])))
and not re.search(LAYOUT_RE, el.get("id", ""))
]
if candidates:
content_el = max(candidates, key=lambda el: len(el.get_text(" ", strip=True)))
body = csoup.find("body")
if body:
body_text_len = len(body.get_text(" ", strip=True))
selected_p_count = len(content_el.find_all("p")) if content_el else 0
selected_text_len = len(content_el.get_text(" ", strip=True)) if content_el else 0
# Codeysworld stores story text as direct <p> children of body.
if not content_el or selected_p_count < 3 or selected_text_len < int(body_text_len * 0.35):
content_el = body
if not content_el:
content_el = body
# Strip site boilerplate: headings (title/byline), navigation links,
# audio links and empty nodes — anywhere in the content element.
if content_el:
# Remove all h1/h2 headings (title and "by Author")
for el in content_el.find_all(["h1", "h2"]):
el.decompose()
# Remove navigation links ("Back to …", "Home", etc.)
for el in content_el.find_all("a", href=True):
text = el.get_text(strip=True)
if re.search(r"back\s+to|<{0,2}\s*back|home", text, re.I):
parent = el.parent
el.decompose()
# Remove the parent too if it's now empty
if parent and not parent.get_text(strip=True):
parent.decompose()
# Remove audio links (links to .mp3 files or containing "listen"/"audio")
for el in content_el.find_all("a", href=True):
href = el.get("href", "")
text = el.get_text(strip=True)
if re.search(r"\.mp3$", href, re.I) or re.search(r"listen|audio", text, re.I):
parent = el.parent
el.decompose()
if parent and not parent.get_text(strip=True):
parent.decompose()
# Remove email links ("Email Author")
for el in content_el.find_all("a", href=re.compile(r"^mailto:", re.I)):
parent = el.parent
el.decompose()
if parent and not parent.get_text(strip=True):
parent.decompose()
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,
}

View File

@ -1,267 +0,0 @@
import re
from urllib.parse import urljoin, urlparse
import httpx
from bs4 import BeautifulSoup, NavigableString
from .base import BaseScraper
IOMFATS_BASE = "https://iomfats.org"
class IomfatsScraper(BaseScraper):
@classmethod
def matches(cls, url: str) -> bool:
return "iomfats.org" in url
async def login(self, client: httpx.AsyncClient, username: str, password: str) -> bool:
return True # no login required
def _author_page_url(self, url: str) -> str:
"""Derive the author index page URL from any iomfats.org URL."""
parsed = urlparse(url)
parts = parsed.path.strip("/").split("/")
# Path: storyshelf/hosted/{author}/...
# Author page is the first 3 segments.
if len(parts) >= 3 and parts[0] == "storyshelf" and parts[1] == "hosted":
author_path = "/" + "/".join(parts[:3]) + "/"
return f"{parsed.scheme}://{parsed.netloc}{author_path}"
return url
def _is_author_page(self, url: str) -> bool:
parts = urlparse(url).path.strip("/").split("/")
return (
len(parts) <= 3
and len(parts) >= 2
and parts[0] == "storyshelf"
and parts[1] == "hosted"
)
def _story_folder(self, url: str) -> str | None:
"""Return the story folder segment from a chapter URL, or None."""
parts = urlparse(url).path.strip("/").split("/")
# storyshelf/hosted/{author}/{story-folder}/{chapter}.html
if len(parts) >= 5:
return parts[3]
return None
async def fetch_book_info(self, client: httpx.AsyncClient, url: str) -> dict:
if self._is_author_page(url):
raise ValueError(
"Voer een chapter-URL in, geen author-pagina. "
"Kopieer de URL van het eerste hoofdstuk van het gewenste verhaal."
)
story_folder = self._story_folder(url)
if not story_folder:
raise ValueError(
"Onverwacht URL-formaat voor iomfats.org. "
"Gebruik de URL van een hoofdstuk, bijv. …/grasshopper/dreamchasers/01.html"
)
author_url = self._author_page_url(url)
r = await client.get(author_url)
soup = BeautifulSoup(r.text, "html.parser")
content = soup.find("div", id="content")
if not content:
raise ValueError("Kan de author-pagina niet verwerken (geen #content element).")
# Author name from "by <a>Name</a>" heading
author = "Unknown author"
for el in content.find_all(["h2", "h3"]):
text = el.get_text(strip=True)
m = re.match(r"^by\s+(.+)$", text, re.I)
if m:
author = m.group(1).strip()
break
# Fallback: author slug from URL
if author == "Unknown author":
parts = urlparse(author_url).path.strip("/").split("/")
if len(parts) >= 3:
author = parts[2].replace("_", " ").title()
# Walk the content to find the story matching story_folder.
#
# Two structures on the author page:
#
# Single story:
# <h3>Book Title</h3>
# <ul><li><a href="folder/ch01.html">Chapter 1</a></li>…</ul>
#
# Multi-part series:
# <h3>Series Name</h3>
# <ul>
# <li><h3>Book Title (part 1)</h3>
# <p><small>[status]</small></p>
# <ul><li><a href="folder-part1/ch01.html">…</a></li>…</ul>
# </li>
# <li><h3>Book Title (part 2)</h3>…</li>
# </ul>
book_title = ""
series = ""
series_index_hint = 0
publication_status = ""
chapter_links: list[dict] = []
nodes = list(content.children)
i = 0
while i < len(nodes):
node = nodes[i]
if not hasattr(node, "name"):
i += 1
continue
if node.name == "h3":
outer_title = node.get_text(strip=True)
if re.match(r"^by\s+", outer_title, re.I):
i += 1
continue
# Find the following <ul>
j = i + 1
outer_ul = None
outer_status = ""
while j < len(nodes):
n = nodes[j]
if not hasattr(n, "name"):
j += 1
continue
if n.name == "h3":
break
if n.name == "p":
small = n.find("small")
if small:
outer_status = small.get_text(strip=True).strip("[]")
j += 1
continue
if n.name == "ul":
outer_ul = n
break
j += 1
if not outer_ul:
i += 1
continue
# Check if this <ul> has sub-section <li><h3> entries (multi-part series)
sub_sections = [
li for li in outer_ul.find_all("li", recursive=False)
if li.find("h3")
]
if sub_sections:
# Multi-part series: outer_title = series name
for li in sub_sections:
sub_h3 = li.find("h3")
sub_ul = li.find("ul")
if not sub_h3 or not sub_ul:
continue
chapters = self._collect_chapters(sub_ul, story_folder, author_url)
if chapters:
book_title = sub_h3.get_text(strip=True)
series = outer_title
chapter_links = chapters
# Status from <p><small> inside this <li>
small = li.find("small")
if small:
publication_status = small.get_text(strip=True).strip("[]")
break
else:
# Single story: outer_title = book title
chapters = self._collect_chapters(outer_ul, story_folder, author_url)
if chapters:
book_title = outer_title
publication_status = outer_status
chapter_links = chapters
if chapter_links:
break
i += 1
if not chapter_links:
raise ValueError(
f"Geen hoofdstukken gevonden voor folder '{story_folder}'. "
"Controleer de URL."
)
# Series index from folder name suffix: *-part{N} or *-{N}
m = re.search(r"-part(\d+)$", story_folder, re.I)
if not m:
m = re.search(r"-(\d+)$", story_folder)
if m:
series_index_hint = int(m.group(1))
return {
"title": book_title or story_folder.replace("-", " ").title(),
"author": author,
"publisher": "iomfats.org",
"series": series,
"series_index_hint": series_index_hint,
"genres": [],
"subgenres": [],
"tags": [],
"description": "",
"updated_date": "",
"publication_status": publication_status,
"source_url": url,
"chapters": chapter_links,
"chapter_method": "html_scan",
"index_image_url": None,
}
def _collect_chapters(self, ul, story_folder: str, base_url: str) -> list[dict]:
"""Collect chapter links from a flat <ul>, filtered by story_folder."""
out: list[dict] = []
for li in ul.find_all("li", recursive=False):
a = li.find("a", href=True)
if not a:
continue
full_url = urljoin(base_url, a["href"])
if story_folder not in urlparse(full_url).path:
continue
raw_title = a.get_text(strip=True)
title = f"Chapter {raw_title}" if re.match(r"^\d+$", raw_title) else raw_title
out.append({"url": full_url, "title": title})
return out
async def fetch_chapter(self, client: httpx.AsyncClient, ch: dict) -> dict:
cr = await client.get(ch["url"])
csoup = BeautifulSoup(cr.text, "html.parser")
title = ch["title"]
content_el = csoup.find("div", id="content")
if not content_el:
content_el = csoup.find("body")
if content_el:
# Remove headings (story title, author, chapter number)
for el in content_el.find_all(["h2", "h3"]):
el.decompose()
# Remove chapter navigation divs
for el in content_el.find_all("div", class_=re.compile(r"chapternav", re.I)):
el.decompose()
# Remove footer elements (author note, forum button)
for el in content_el.find_all("div", class_="important"):
el.decompose()
for el in content_el.find_all("a", class_="styled-button"):
parent = el.parent
el.decompose()
if parent and not parent.get_text(strip=True):
parent.decompose()
# Remove anchor tags used as page anchors (<a name="content">)
for el in content_el.find_all("a", attrs={"name": True}):
if not el.get("href"):
el.unwrap()
return {
"title": title,
"content_el": content_el,
"selector_id": "content",
"selector_class": None,
}

View File

@ -1,358 +0,0 @@
import re
from email.utils import parsedate
from html import escape as he
from time import mktime
from datetime import date as _date
from urllib.parse import urljoin, urlparse
import httpx
from bs4 import BeautifulSoup
from .base import BaseScraper
# Email header field names that appear at the top of Nifty classic chapters.
_HEADER_RE = re.compile(
r"^(Date|From|Subject|Reply-To|Message-ID|MIME-Version|Content-Type|X-[\w-]+):",
re.I,
)
# Scene-break patterns in plain text (subset of xhtml.BREAK_PATTERNS for text matching).
_BREAK_RE = re.compile(
r"^("
r"[\*\-]{3,}"
r"|[~=]{3,}"
r"|#{3,}"
r"|[·•◦‣⁃]\s*[·•◦‣⁃]\s*[·•◦‣⁃]"
r"|[-–—]\s*[oO0]\s*[-–—]"
r")$"
)
class NiftyScraper(BaseScraper):
_LEAD_MARKERS = (
"notice this is a work of fiction",
"if it is illegal to read stories",
"if you enjoy this story",
"for my other stories",
"nifty archive",
"code of conduct",
"author note",
"author's note",
"disclaimer",
"this story contains",
"this story includes",
"all characters are",
"all characters depicted",
)
_TAIL_MARKERS = (
"please remember to donate",
"donate",
"support nifty",
"support the archive",
"nifty archive alliance",
"donate.nifty.org",
"nifty.org/donate",
"nifty.org/support",
"patreon",
"buy me a coffee",
"tip jar",
"become a supporter",
)
@classmethod
def matches(cls, url: str) -> bool:
return "nifty.org" in url and "new.nifty.org" not in url
async def login(self, client: httpx.AsyncClient, username: str, password: str) -> bool:
return True # no login required
# ── Helpers ───────────────────────────────────────────────────────────────
def _to_index_url(self, url: str) -> str:
"""Return the story index URL for any Nifty URL (index or chapter).
Nifty path structure:
/nifty/{category}/{subcategory}/{story}/ index (4 segments)
/nifty/{category}/{subcategory}/{story}/{chapter} chapter (5 segments)
"""
parsed = urlparse(url)
parts = [p for p in parsed.path.split("/") if p]
if len(parts) >= 5:
path = "/" + "/".join(parts[:4]) + "/"
else:
path = parsed.path.rstrip("/") + "/"
return f"{parsed.scheme}://{parsed.netloc}{path}"
def _slug_to_title(self, slug: str) -> str:
return slug.replace("-", " ").title()
def _parse_date_header(self, text: str) -> str:
"""Return YYYY-MM-DD from a 'Date: …' line, or ''."""
m = re.search(r"^Date:\s+(.+)$", text, re.M)
if not m:
return ""
try:
parsed = parsedate(m.group(1).strip())
if parsed:
return _date.fromtimestamp(mktime(parsed)).isoformat()
except Exception:
pass
return ""
def _parse_author_header(self, text: str) -> str:
"""Return author name from 'From: Name <email>' line, or ''."""
m = re.search(r"^From:\s+([^<\n]+?)(?:\s*<[^>]+>)?\s*$", text, re.M)
return m.group(1).strip() if m else ""
def _parse_subject_header(self, text: str) -> str:
"""Return the Subject header value, or ''."""
m = re.search(r"^Subject:\s+(.+)$", text, re.M)
return m.group(1).strip() if m else ""
def _normalize(self, text: str) -> str:
"""Normalise text for boilerplate comparison (lowercase, collapsed whitespace)."""
return re.sub(r"\s+", " ", text.lower()).strip()
async def _get_text(self, client: httpx.AsyncClient, url: str) -> tuple[BeautifulSoup, str]:
"""Fetch *url* and return (soup, raw_text).
Nifty classic pages wrap the story content in a <pre> element.
Falls back to the full body text if no <pre> is found.
"""
r = await client.get(url)
soup = BeautifulSoup(r.text, "html.parser")
pre = soup.find("pre")
if pre:
raw = pre.get_text()
else:
body = soup.find("body")
raw = body.get_text("\n") if body else soup.get_text("\n")
return soup, raw
def _strip_email_headers(self, text: str) -> str:
"""Remove the leading email header block (Date/From/Subject/…) from chapter text.
Tolerates blank lines between header fields some Nifty pages place the
Subject on a separate line after a blank line:
Date:
From:
Subject:
"""
lines = text.splitlines()
i = 0
# Skip leading blank lines.
while i < len(lines) and not lines[i].strip():
i += 1
# Only strip if this actually looks like an email header block.
if not any(_HEADER_RE.match(lines[j]) for j in range(i, min(i + 12, len(lines)))):
return text
# Skip header lines, tolerating blank lines between them.
# A blank line ends the block only when no further header line follows.
while i < len(lines):
stripped = lines[i].strip()
if _HEADER_RE.match(stripped):
i += 1
elif not stripped:
# Peek ahead past any blank lines.
j = i + 1
while j < len(lines) and not lines[j].strip():
j += 1
if j < len(lines) and _HEADER_RE.match(lines[j].strip()):
i = j # more headers follow — jump over the blank line(s)
else:
i += 1
break # no more headers — end of block
else:
break # non-header, non-blank line — end of block
# Skip blank lines immediately after the header block.
while i < len(lines) and not lines[i].strip():
i += 1
return "\n".join(lines[i:])
def _text_to_paragraphs(self, text: str) -> list[str]:
"""Split plain text into paragraphs; join hard-wrapped lines within each paragraph.
Nifty classic stories are stored as email submissions: paragraphs are
separated by blank lines, and each line is wrapped at ~70 characters.
This function merges those wrapped lines back into a single line per
paragraph.
"""
text = text.replace("\r\n", "\n").replace("\r", "\n")
blocks = re.split(r"\n{2,}", text)
result = []
for block in blocks:
lines = [l.strip() for l in block.splitlines() if l.strip()]
if lines:
result.append(" ".join(lines))
return result
def _comment_safe(self, text: str) -> str:
return text.replace("--", "- -")
def _plain_text(self, text: str) -> str:
if "<" in text and ">" in text:
return BeautifulSoup(text, "html.parser").get_text(" ", strip=True)
return text
def _looks_like_lead_boilerplate(self, text: str) -> bool:
t = re.sub(r"\s+", " ", self._plain_text(text).lower()).strip()
if not t or len(t) > 4000:
return False
return any(m in t for m in self._LEAD_MARKERS)
def _looks_like_tail_boilerplate(self, text: str) -> bool:
t = re.sub(r"\s+", " ", self._plain_text(text).lower()).strip()
if not t or len(t) > 4000:
return False
return any(m in t for m in self._TAIL_MARKERS)
def _extract_hidden_boilerplate(self, paragraphs: list[str]) -> tuple[list[str], list[str], list[str]]:
visible = list(paragraphs)
leading: list[str] = []
trailing: list[str] = []
while visible and len(leading) < 6 and self._looks_like_lead_boilerplate(visible[0]):
leading.append(visible.pop(0))
while visible and len(trailing) < 6 and self._looks_like_tail_boilerplate(visible[-1]):
trailing.insert(0, visible.pop())
if not visible:
return list(paragraphs), [], []
return visible, leading, trailing
# ── BaseScraper interface ─────────────────────────────────────────────────
async def fetch_book_info(self, client: httpx.AsyncClient, url: str) -> dict:
index_url = self._to_index_url(url)
r = await client.get(index_url)
soup = BeautifulSoup(r.text, "html.parser")
# Title from URL slug.
slug = urlparse(index_url).path.rstrip("/").rsplit("/", 1)[-1]
book_title = self._slug_to_title(slug)
# Genres from URL path: /nifty/{category}/{subcategory}/{story}/
path_parts = [p for p in urlparse(index_url).path.split("/") if p]
category = self._slug_to_title(path_parts[1]) if len(path_parts) > 1 else ""
subcategory = self._slug_to_title(path_parts[2]) if len(path_parts) > 2 else ""
# Chapter links: all <a> tags pointing one level deeper than the index.
chapter_links: list[dict] = []
seen: set[str] = set()
for a in soup.find_all("a", href=True):
full = urljoin(index_url, a["href"])
if (
full.startswith(index_url)
and full.rstrip("/") != index_url.rstrip("/")
and full not in seen
):
seen.add(full)
chapter_links.append({"url": full, "title": a.get_text(strip=True)})
# Sort by trailing chapter number.
def _num(ch: dict) -> int:
m = re.search(r"-(\d+)$", ch["url"].rstrip("/"))
return int(m.group(1)) if m else 0
chapter_links.sort(key=_num)
for i, ch in enumerate(chapter_links, 1):
ch["title"] = f"Chapter {i}"
# Author and dates: extract from email headers in first and last chapters.
author = "Unknown author"
updated_date = ""
preamble_count = 0
if chapter_links:
_, first_text = await self._get_text(client, chapter_links[0]["url"])
author = self._parse_author_header(first_text) or author
pub_date = self._parse_date_header(first_text)
if len(chapter_links) > 1:
_, last_text = await self._get_text(client, chapter_links[-1]["url"])
updated_date = self._parse_date_header(last_text) or pub_date
else:
updated_date = pub_date
# Boilerplate detection: compare leading paragraphs of chapters 1 and 2.
# Paragraphs present in both (after header strip) are repeated preamble.
if len(chapter_links) >= 2:
_, ch2_text = await self._get_text(client, chapter_links[1]["url"])
paras1 = self._text_to_paragraphs(self._strip_email_headers(first_text))
paras2 = self._text_to_paragraphs(self._strip_email_headers(ch2_text))
for p1, p2 in zip(paras1, paras2):
if self._normalize(p1) == self._normalize(p2):
preamble_count += 1
else:
break
for ch in chapter_links:
ch["preamble_count"] = preamble_count
return {
"title": book_title,
"author": author,
"publisher": "nifty.org",
"series": "",
"series_index_hint": 0,
"genres": [],
"subgenres": [],
"tags": [t for t in [category, subcategory] if t],
"description": "",
"updated_date": updated_date,
"publication_status": "",
"source_url": index_url,
"chapters": chapter_links,
"chapter_method": "html_scan",
"index_image_url": None,
}
async def fetch_chapter(self, client: httpx.AsyncClient, ch: dict) -> dict:
_, raw_text = await self._get_text(client, ch["url"])
# Extract Subject before stripping headers; store as invisible comment.
subject = self._parse_subject_header(raw_text)
# Remove email header block.
story_text = self._strip_email_headers(raw_text)
# Convert hard-wrapped plain text to paragraphs.
paragraphs = self._text_to_paragraphs(story_text)
# Skip repeated boilerplate paragraphs at the top of each chapter.
preamble_count = ch.get("preamble_count", 0)
if preamble_count:
paragraphs = paragraphs[preamble_count:]
paragraphs, hidden_lead, hidden_tail = self._extract_hidden_boilerplate(paragraphs)
# Build an HTML fragment: subject as comment, scene-breaks as <hr/>, rest as <p>.
html_parts: list[str] = []
if subject:
html_parts.append(f"<!-- Subject: {self._comment_safe(subject)} -->")
if hidden_lead:
lead_text = " || ".join(re.sub(r"\s+", " ", p).strip() for p in hidden_lead if p.strip())
if lead_text:
html_parts.append(f"<!-- NIFTY_HIDDEN_LEAD: {self._comment_safe(lead_text)} -->")
for para in paragraphs:
if _BREAK_RE.match(para.strip()):
html_parts.append("<hr/>")
else:
html_parts.append(f"<p>{he(para)}</p>")
if hidden_tail:
tail_text = " || ".join(re.sub(r"\s+", " ", p).strip() for p in hidden_tail if p.strip())
if tail_text:
html_parts.append(f"<!-- NIFTY_HIDDEN_TAIL: {self._comment_safe(tail_text)} -->")
wrapper = BeautifulSoup(
"<div>" + "".join(html_parts) + "</div>",
"html.parser",
)
content_el = wrapper.find("div")
return {
"title": ch["title"],
"content_el": content_el,
"selector_id": None,
"selector_class": None,
}

View File

@ -1,310 +0,0 @@
import json
import re
from html import unescape as html_unescape
from urllib.parse import urlparse
import httpx
from bs4 import BeautifulSoup, Comment
from .base import BaseScraper
class NiftyNewScraper(BaseScraper):
_LEAD_MARKERS = (
"notice this is a work of fiction",
"if it is illegal to read stories",
"if you enjoy this story",
"for my other stories",
"nifty archive",
"code of conduct",
"author note",
"author's note",
"disclaimer",
"this story contains",
"this story includes",
"all characters are",
"all characters depicted",
)
_TAIL_MARKERS = (
"please remember to donate",
"donate",
"support nifty",
"support the archive",
"nifty archive alliance",
"donate.nifty.org",
"nifty.org/donate",
"nifty.org/support",
"patreon",
"buy me a coffee",
"tip jar",
"become a supporter",
)
@classmethod
def matches(cls, url: str) -> bool:
return "new.nifty.org" in url
async def login(self, client: httpx.AsyncClient, username: str, password: str) -> bool:
return True # no login required
# ── Helpers ───────────────────────────────────────────────────────────────
def _to_index_url(self, url: str) -> str:
"""Strip trailing chapter number, return story index URL.
/stories/some-slug-83036/3 /stories/some-slug-83036
/stories/some-slug-83036 /stories/some-slug-83036
"""
parsed = urlparse(url)
path = re.sub(r"/\d+$", "", parsed.path.rstrip("/"))
return f"{parsed.scheme}://{parsed.netloc}{path}"
def _parse_date(self, iso: str) -> str:
"""Return YYYY-MM-DD from an ISO datetime string, or ''."""
if not iso:
return ""
return iso[:10]
# ── BaseScraper interface ─────────────────────────────────────────────────
async def fetch_book_info(self, client: httpx.AsyncClient, url: str) -> dict:
index_url = self._to_index_url(url)
r = await client.get(index_url)
soup = BeautifulSoup(r.text, "html.parser")
# Title: <h1>, fallback to <title> (strip "- … - Nifty Archive …" suffix)
h1 = soup.find("h1")
if h1:
title = h1.get_text(strip=True)
else:
title_el = soup.find("title")
raw = title_el.get_text(strip=True) if title_el else ""
title = re.split(r"\s+[-]\s+", raw)[0].strip() if raw else ""
# Author: <strong itemprop="name"> inside /authors/ link
author = "Unknown author"
author_link = soup.find("a", href=re.compile(r"^/authors/\d+"))
if author_link:
name_el = author_link.find("strong", itemprop="name")
if name_el:
author = name_el.get_text(strip=True)
# Dates: <time itemprop="datePublished/dateModified">
pub_el = soup.find("time", itemprop="datePublished")
mod_el = soup.find("time", itemprop="dateModified")
pub_date = self._parse_date(pub_el.get("datetime", "") if pub_el else "")
updated_date = self._parse_date(mod_el.get("datetime", "") if mod_el else "") or pub_date
# Tags: from all <ul aria-label="Tags"> containers (category links + generated tags)
tags: list[str] = []
seen: set[str] = set()
for ul in soup.find_all("ul", attrs={"aria-label": "Tags"}):
for a in ul.find_all("a", href=True):
label = a.get_text(strip=True)
if label and label.lower() not in seen:
seen.add(label.lower())
tags.append(label)
# Description: <meta name="description">
desc = ""
meta_desc = soup.find("meta", attrs={"name": "description"})
if meta_desc and meta_desc.get("content"):
desc = meta_desc["content"].strip()
# Chapters: find /stories/{slug}/N links in the page HTML
slug_path = urlparse(index_url).path # e.g. /stories/some-slug-83036
chapter_pattern = re.compile(r"^" + re.escape(slug_path) + r"/(\d+)$")
chapter_nums: set[int] = set()
for a in soup.find_all("a", href=True):
m = chapter_pattern.match(a["href"])
if m:
chapter_nums.add(int(m.group(1)))
# Fallback: scan RSC stream for chapter index values
if not chapter_nums:
for m in re.finditer(r'"index"\s*:\s*(\d+)', r.text):
chapter_nums.add(int(m.group(1)))
if not chapter_nums:
chapter_nums = {1}
chapters = [
{"url": f"{index_url}/{i}", "title": f"Chapter {i}"}
for i in range(1, max(chapter_nums) + 1)
]
return {
"title": title,
"author": author,
"publisher": "nifty.org",
"series": "",
"series_index_hint": 0,
"genres": [],
"subgenres": [],
"tags": tags,
"description": desc,
"updated_date": updated_date,
"publication_status": "",
"source_url": index_url,
"chapters": chapters,
"chapter_method": "html_scan",
"index_image_url": None,
}
# ── RSC parser ───────────────────────────────────────────────────────────
def _parse_rsc_paragraphs(self, rsc_text: str) -> list[str]:
"""Extract story paragraph text from a Next.js RSC stream.
The RSC format is a series of lines: ``{hex_id}:{json_value}``.
Each line that represents a <p> element looks like:
2c:["$","p",null,{"children":"Paragraph text."}]
"""
paragraphs: list[str] = []
for line in rsc_text.splitlines():
colon = line.find(":")
if colon < 0:
continue
try:
node = json.loads(line[colon + 1:])
except Exception:
continue
paragraphs.extend(self._rsc_find_paragraphs(node))
return paragraphs
def _rsc_find_paragraphs(self, node) -> list[str]:
"""Recursively find <p> text in an RSC component tree node."""
if not isinstance(node, list):
return []
# React element: ["$", tagname, key, props]
if len(node) >= 4 and node[0] == "$" and isinstance(node[1], str):
tag = node[1]
props = node[3] if isinstance(node[3], dict) else {}
if tag == "p":
text = self._rsc_text(props.get("children", ""))
return [text] if text.strip() else []
children = props.get("children")
if children is not None:
return self._rsc_find_paragraphs(children)
return []
# Plain list of child nodes
result: list[str] = []
for item in node:
result.extend(self._rsc_find_paragraphs(item))
return result
def _rsc_text(self, children) -> str:
"""Flatten RSC children (string or nested array) into plain text."""
if isinstance(children, str):
return children if not children.startswith("$") else ""
if isinstance(children, list):
parts: list[str] = []
for item in children:
if isinstance(item, str) and not item.startswith("$"):
parts.append(item)
elif isinstance(item, list) and len(item) >= 4 and item[0] == "$":
inner = item[3] if isinstance(item[3], dict) else {}
parts.append(self._rsc_text(inner.get("children", "")))
return "".join(parts)
return ""
def _extract_escaped_html_paragraphs(self, text: str) -> list[str]:
"""Extract \\u003cp\\u003e...\\u003c/p\\u003e paragraphs from Next payload text."""
paragraphs: list[str] = []
for raw in re.findall(r"\\u003cp\\u003e(.*?)\\u003c/p\\u003e", text, flags=re.S):
try:
decoded = bytes(raw, "utf-8").decode("unicode_escape")
except Exception:
decoded = raw
decoded = html_unescape(decoded)
decoded = re.sub(r"\s+", " ", decoded).strip()
if decoded:
paragraphs.append(decoded)
return paragraphs
def _comment_safe(self, text: str) -> str:
return text.replace("--", "- -")
def _plain_text(self, text: str) -> str:
# Some payload variants contain inline HTML inside paragraph text.
# Convert to plain text before marker matching.
if "<" in text and ">" in text:
return BeautifulSoup(text, "html.parser").get_text(" ", strip=True)
return text
def _looks_like_lead_boilerplate(self, text: str) -> bool:
t = re.sub(r"\s+", " ", self._plain_text(text).lower()).strip()
if not t or len(t) > 4000:
return False
return any(m in t for m in self._LEAD_MARKERS)
def _looks_like_tail_boilerplate(self, text: str) -> bool:
t = re.sub(r"\s+", " ", self._plain_text(text).lower()).strip()
if not t or len(t) > 4000:
return False
return any(m in t for m in self._TAIL_MARKERS)
def _extract_hidden_boilerplate(self, paragraphs: list[str]) -> tuple[list[str], list[str], list[str]]:
visible = list(paragraphs)
leading: list[str] = []
trailing: list[str] = []
while visible and len(leading) < 6 and self._looks_like_lead_boilerplate(visible[0]):
leading.append(visible.pop(0))
while visible and len(trailing) < 6 and self._looks_like_tail_boilerplate(visible[-1]):
trailing.insert(0, visible.pop())
# Never return an empty chapter due to over-eager filtering.
if not visible:
return list(paragraphs), [], []
return visible, leading, trailing
async def fetch_chapter(self, client: httpx.AsyncClient, ch: dict) -> dict:
# Primary path: fetch chapter HTML and read the rendered <article> content.
r = await client.get(ch["url"])
soup = BeautifulSoup(r.text, "html.parser")
paragraphs: list[str] = []
article = soup.find("article")
if article:
for p in article.find_all("p"):
text = p.get_text(" ", strip=True)
if text:
paragraphs.append(text)
# Fallback: paragraph HTML may only appear escaped in Next payload scripts.
if not paragraphs:
paragraphs = self._extract_escaped_html_paragraphs(r.text)
# Last fallback: request ?_rsc=1 and parse both RSC line format + escaped chunks.
if not paragraphs:
r_rsc = await client.get(ch["url"] + "?_rsc=1")
paragraphs = self._parse_rsc_paragraphs(r_rsc.text)
if not paragraphs:
paragraphs = self._extract_escaped_html_paragraphs(r_rsc.text)
paragraphs, hidden_lead, hidden_tail = self._extract_hidden_boilerplate(paragraphs)
# Build a BeautifulSoup <div> with <p> elements.
wrapper = BeautifulSoup("", "html.parser")
div = wrapper.new_tag("div")
if hidden_lead:
lead_text = " || ".join(re.sub(r"\s+", " ", p).strip() for p in hidden_lead if p.strip())
if lead_text:
div.append(Comment(self._comment_safe(f"NIFTY_HIDDEN_LEAD: {lead_text}")))
for text in paragraphs:
p = wrapper.new_tag("p")
p.string = text
div.append(p)
if hidden_tail:
tail_text = " || ".join(re.sub(r"\s+", " ", p).strip() for p in hidden_tail if p.strip())
if tail_text:
div.append(Comment(self._comment_safe(f"NIFTY_HIDDEN_TAIL: {tail_text}")))
return {
"title": ch["title"],
"content_el": div,
"selector_id": None,
"selector_class": None,
}

View File

@ -1,139 +0,0 @@
import re
from urllib.parse import urljoin
import httpx
from bs4 import BeautifulSoup
from .base import BaseScraper
TED_BASE = "https://tedlouis.com/"
class TedLouisScraper(BaseScraper):
@classmethod
def matches(cls, url: str) -> bool:
return "tedlouis.com" in url
async def login(self, client: httpx.AsyncClient, username: str, password: str) -> bool:
return True # no login required
async def fetch_book_info(self, client: httpx.AsyncClient, url: str) -> dict:
r = await client.get(url)
soup = BeautifulSoup(r.text, "html.parser")
# Detect chapter page (wrong entry point)
if soup.find("h1", class_="story-title") and not soup.find("h2", class_="story-page-title"):
raise ValueError(
"Voer de story index-URL in, geen chapter-URL. "
"Kopieer de URL van de verhaal-indexpagina (de pagina met de hoofdstukkenlijst)."
)
# Title: extract only direct NavigableString children from the h2,
# ignoring nested elements like the "Back" link and author byline.
book_title = "Unknown title"
title_el = soup.find("h2", class_="story-page-title")
if title_el:
from bs4 import NavigableString
parts = [
str(c).strip()
for c in title_el.children
if isinstance(c, NavigableString) and str(c).strip()
]
book_title = " ".join(parts) or title_el.get_text(strip=True)
# Author: from byline span (may be inside the h2 or elsewhere)
author = "Unknown author"
byline = soup.find("span", class_="story-author-by-line")
if byline:
a = byline.find("a")
if a:
author = a.get_text(strip=True)
# Publication status
status_el = soup.find("span", class_="story-status-text")
publication_status = ""
if status_el:
raw = status_el.get_text(strip=True)
publication_status = re.sub(r"^Status:\s*", "", raw, flags=re.I).strip()
# Updated date: "Last Updated: Month D, YYYY" → "YYYY-MM-DD"
updated_date = ""
updated_el = soup.find("span", class_="story-last-updated")
if updated_el:
raw = re.sub(r"^Last\s+Updated:\s*", "", updated_el.get_text(strip=True), flags=re.I).strip()
try:
from datetime import datetime
updated_date = datetime.strptime(raw, "%B %d, %Y").strftime("%Y-%m-%d")
except ValueError:
try:
updated_date = datetime.strptime(raw, "%B %Y").strftime("%Y-%m-01")
except ValueError:
pass
# Chapter links from all story-index-list columns
actual_url = str(r.url)
chapter_links: list[dict] = []
seen: set[str] = set()
for ul in soup.find_all("ul", class_="story-index-list"):
for li in ul.find_all("li"):
a = li.find("a", href=True)
if not a:
continue
href = a["href"]
full_url = urljoin(actual_url, href)
if full_url in seen:
continue
seen.add(full_url)
chapter_links.append({"url": full_url, "title": a.get_text(strip=True)})
return {
"title": book_title,
"author": author,
"publisher": "tedlouis.com",
"series": "",
"series_index_hint": 0,
"genres": [],
"subgenres": [],
"tags": [],
"description": "",
"updated_date": updated_date,
"publication_status": publication_status,
"source_url": url,
"chapters": chapter_links,
"chapter_method": "html_scan",
"index_image_url": None,
}
async def fetch_chapter(self, client: httpx.AsyncClient, ch: dict) -> dict:
cr = await client.get(ch["url"])
csoup = BeautifulSoup(cr.text, "html.parser")
title = ch["title"]
# Refine chapter title from <h2 class="chapter-title"><span>…</span></h2>
chapter_h2 = csoup.find("h2", class_="chapter-title")
if chapter_h2:
span = chapter_h2.find("span")
refined = (span or chapter_h2).get_text(strip=True)
if refined:
title = refined
content_el = csoup.find("div", id="chapter")
if content_el:
# Remove story title, chapter title, copyright blocks
for el in content_el.find_all("h1", class_="story-title"):
el.decompose()
for el in content_el.find_all("h2", class_="chapter-title"):
el.decompose()
for el in content_el.find_all("div", class_="chapter-copyright-line"):
el.decompose()
for el in content_el.find_all("div", class_=re.compile(r"chapter-copyright-notice", re.I)):
el.decompose()
return {
"title": title,
"content_el": content_el,
"selector_id": "chapter",
"selector_class": None,
}

View File

@ -1,18 +0,0 @@
from fastapi.templating import Jinja2Templates
from db import get_db_conn
def _develop_mode() -> bool:
try:
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT develop_mode FROM app_settings WHERE id = 1")
row = cur.fetchone()
return bool(row[0]) if row else False
except Exception:
return False
templates = Jinja2Templates(directory="templates")
templates.env.globals["develop_mode"] = _develop_mode

View File

@ -19,6 +19,23 @@ if (BOOK.has_cover) {
let currentRating = BOOK.rating || 0; let currentRating = BOOK.rating || 0;
async function rateBook(rating) {
const newRating = currentRating === rating ? 0 : rating;
try {
const resp = await fetch(`/library/rating/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: newRating }),
});
const result = await resp.json();
if (!resp.ok || result.error) return;
currentRating = result.rating;
document.querySelectorAll('#book-stars .star').forEach((el, idx) => {
el.classList.toggle('filled', idx + 1 <= currentRating);
});
} catch {}
}
// ── Want to Read toggle ──────────────────────────────────────────────────── // ── Want to Read toggle ────────────────────────────────────────────────────
async function toggleWtr() { async function toggleWtr() {
@ -148,8 +165,8 @@ class PillInput {
} }
_add(v) { _add(v) {
const parts = v.split(',').map(p => p.trim()).filter(p => p && !this.values.includes(p)); v = v.trim();
if (parts.length) { this.values.push(...parts); this._render(); } if (v && !this.values.includes(v)) { this.values.push(v); this._render(); }
this.input.value = ''; this.input.value = '';
this._hideDropdown(); this._hideDropdown();
} }
@ -288,10 +305,8 @@ async function openEdit() {
document.getElementById('ed-author').value = BOOK.author; document.getElementById('ed-author').value = BOOK.author;
document.getElementById('ed-publisher').value = BOOK.publisher; document.getElementById('ed-publisher').value = BOOK.publisher;
document.getElementById('ed-series').value = BOOK.series; document.getElementById('ed-series').value = BOOK.series;
document.getElementById('ed-series-volume').value = BOOK.series_volume || '';
document.getElementById('ed-series-index').value = BOOK.series_index + (BOOK.series_suffix || ''); document.getElementById('ed-series-index').value = BOOK.series_index + (BOOK.series_suffix || '');
document.getElementById('ed-status').value = BOOK.publication_status || 'Complete'; document.getElementById('ed-status').value = BOOK.publication_status || 'Complete';
document.getElementById('ed-rating').value = String(currentRating);
document.getElementById('ed-url').value = BOOK.source_url; document.getElementById('ed-url').value = BOOK.source_url;
document.getElementById('ed-publish-date').value = BOOK.publish_date; document.getElementById('ed-publish-date').value = BOOK.publish_date;
document.getElementById('ed-description').value = BOOK.description; document.getElementById('ed-description').value = BOOK.description;
@ -304,17 +319,6 @@ async function openEdit() {
document.getElementById('edit-panel').classList.add('open'); document.getElementById('edit-panel').classList.add('open');
} }
function generateTitle() {
const series = document.getElementById('ed-series').value.trim();
if (!series) return;
const volume = document.getElementById('ed-series-volume').value.trim();
const index = document.getElementById('ed-series-index').value.trim();
let title = series;
if (volume) title += ` (${volume})`;
if (index) title += ` #${index}`;
document.getElementById('ed-title').value = title;
}
function closeEdit() { function closeEdit() {
document.getElementById('edit-backdrop').classList.remove('open'); document.getElementById('edit-backdrop').classList.remove('open');
document.getElementById('edit-panel').classList.remove('open'); document.getElementById('edit-panel').classList.remove('open');
@ -332,7 +336,6 @@ async function saveEdit() {
author: document.getElementById('ed-author').value, author: document.getElementById('ed-author').value,
publisher: document.getElementById('ed-publisher').value, publisher: document.getElementById('ed-publisher').value,
series: document.getElementById('ed-series').value, series: document.getElementById('ed-series').value,
series_volume: document.getElementById('ed-series-volume').value,
series_index: document.getElementById('ed-series-index').value, series_index: document.getElementById('ed-series-index').value,
publication_status: document.getElementById('ed-status').value, publication_status: document.getElementById('ed-status').value,
source_url: document.getElementById('ed-url').value, source_url: document.getElementById('ed-url').value,
@ -342,27 +345,17 @@ async function saveEdit() {
subgenres: subgenreInput.getValues(), subgenres: subgenreInput.getValues(),
tags: tagInput.getValues(), tags: tagInput.getValues(),
}; };
const newRating = parseInt(document.getElementById('ed-rating').value, 10) || 0;
const resp = await fetch(`/library/book/${encodeURIComponent(filename)}`, { const resp = await fetch(`/library/book/${encodeURIComponent(filename)}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const result = await resp.json(); const result = await resp.json();
if (!resp.ok) { if (resp.ok && result.filename) {
alert(result.error || 'Save failed.');
return;
}
if (newRating !== currentRating) {
await fetch(`/library/rating/${encodeURIComponent(result.filename || filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: newRating }),
});
}
if (result.filename) {
window.location.href = `/library/book/${encodeURIComponent(result.filename)}`; window.location.href = `/library/book/${encodeURIComponent(result.filename)}`;
} else { } else if (resp.ok) {
window.location.reload(); window.location.reload();
} else {
alert(result.error || 'Save failed.');
} }
} }

View File

@ -140,24 +140,6 @@ html, body {
.badge-temporary-hold { color: #c8a03a; } .badge-temporary-hold { color: #c8a03a; }
.badge-long-term-hold { color: #ffa20e; } .badge-long-term-hold { color: #ffa20e; }
/* Archived badge: bottom-left of cover */
.badge-archived {
position: absolute;
bottom: 0.35rem;
left: 0.35rem;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
background: rgba(15,14,12,0.82);
box-shadow: 0 0 0 2px #0f0e0c;
color: #888;
pointer-events: none;
}
/* Star: want-to-read top-left */ /* Star: want-to-read top-left */
.btn-star { .btn-star {
position: absolute; position: absolute;
@ -376,9 +358,6 @@ html, body {
.slot-missing-inner svg { opacity: 0.5; } .slot-missing-inner svg { opacity: 0.5; }
.slot-missing-inner span { font-family: var(--mono); font-size: 0.65rem; } .slot-missing-inner span { font-family: var(--mono); font-size: 0.65rem; }
/* ── Series detail header ─────────────────────────────────────────────────── */
.series-detail-header { display: flex; justify-content: flex-end; margin-bottom: 1rem; }
/* ── Authors list ─────────────────────────────────────────────────────────── */ /* ── Authors list ─────────────────────────────────────────────────────────── */
.author-list { display: flex; flex-direction: column; gap: 0.3rem; } .author-list { display: flex; flex-direction: column; gap: 0.3rem; }

View File

@ -938,7 +938,6 @@ function renderBooksGrid(books) {
</svg> </svg>
</button> </button>
${statusBadge} ${statusBadge}
${b.archived ? `<div class="badge-archived" title="Archived"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg></div>` : ''}
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''} ${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''} ${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
</div> </div>
@ -989,8 +988,6 @@ function groupBySeries() {
map[b.series].push(b); map[b.series].push(b);
} }
for (const s of Object.values(map)) s.sort((a, b) => { for (const s of Object.values(map)) s.sort((a, b) => {
const va = a.series_volume || '', vb = b.series_volume || '';
if (va !== vb) return va.localeCompare(vb);
if (a.series_index !== b.series_index) return a.series_index - b.series_index; if (a.series_index !== b.series_index) return a.series_index - b.series_index;
return (a.series_suffix || '').localeCompare(b.series_suffix || ''); return (a.series_suffix || '').localeCompare(b.series_suffix || '');
}); });
@ -1085,26 +1082,6 @@ function renderSeriesGrid() {
// ── Series detail ────────────────────────────────────────────────────────── // ── Series detail ──────────────────────────────────────────────────────────
function _slotsForBooks(books, seriesVolume) {
const sorted = [...books].sort((a, b) => {
if (a.series_index !== b.series_index) return a.series_index - b.series_index;
return (a.series_suffix || '').localeCompare(b.series_suffix || '');
});
const byIndex = {};
for (const b of sorted) {
if (!byIndex[b.series_index]) byIndex[b.series_index] = [];
byIndex[b.series_index].push(b);
}
const min = Math.min(...sorted.map(b => b.series_index));
const max = Math.max(...sorted.map(b => b.series_index));
const slots = [];
for (let i = min; i <= max; i++) {
if (byIndex[i]) for (const b of byIndex[i]) slots.push(b);
else slots.push({ missing: true, series_index: i, series_volume: seriesVolume || '' });
}
return slots;
}
function getSeriesSlots(books) { function getSeriesSlots(books) {
// Treat books as indexed (including index 0) only when at least one book // Treat books as indexed (including index 0) only when at least one book
// has series_index > 0 — this preserves the "unindexed flat list" behaviour // has series_index > 0 — this preserves the "unindexed flat list" behaviour
@ -1112,24 +1089,29 @@ function getSeriesSlots(books) {
const hasPositiveIndex = books.some(b => b.series_index > 0); const hasPositiveIndex = books.some(b => b.series_index > 0);
if (!hasPositiveIndex) return books; if (!hasPositiveIndex) return books;
// When series_volume is used, do gap-detection per volume (year) separately. // Sort indexed books by (series_index, series_suffix) so 21 < 21a < 21b < 22.
if (books.some(b => b.series_volume)) { const indexed = [...books].sort((a, b) => {
const byVolume = {}; if (a.series_index !== b.series_index) return a.series_index - b.series_index;
for (const b of books) { return (a.series_suffix || '').localeCompare(b.series_suffix || '');
const vol = b.series_volume || ''; });
if (!byVolume[vol]) byVolume[vol] = [];
byVolume[vol].push(b); // Build slot map keyed by numeric index only (for gap detection).
const byIndex = {};
for (const b of indexed) {
if (!byIndex[b.series_index]) byIndex[b.series_index] = [];
byIndex[b.series_index].push(b);
} }
const min = Math.min(...indexed.map(b => b.series_index));
const max = Math.max(...indexed.map(b => b.series_index));
const slots = []; const slots = [];
for (const vol of Object.keys(byVolume).sort()) { for (let i = min; i <= max; i++) {
for (const slot of _slotsForBooks(byVolume[vol], vol)) slots.push(slot); if (byIndex[i]) for (const b of byIndex[i]) slots.push(b);
else slots.push({ missing: true, series_index: i });
} }
return slots; return slots;
} }
return _slotsForBooks(books, '');
}
function renderSeriesDetail(seriesName) { function renderSeriesDetail(seriesName) {
const map = groupBySeries(); const map = groupBySeries();
const books = map[seriesName] || []; const books = map[seriesName] || [];
@ -1152,9 +1134,7 @@ function renderSeriesDetail(seriesName) {
if (hasPositiveIndex || slot.series_index > 0 || slot.series_suffix) { if (hasPositiveIndex || slot.series_index > 0 || slot.series_suffix) {
const lbl = document.createElement('div'); const lbl = document.createElement('div');
lbl.className = 'slot-index-label'; lbl.className = 'slot-index-label';
lbl.textContent = slot.series_volume lbl.textContent = `#${slot.series_index}${slot.series_suffix || ''}`;
? `(${slot.series_volume}) #${slot.series_index}${slot.series_suffix || ''}`
: `#${slot.series_index}${slot.series_suffix || ''}`;
wrapper.appendChild(lbl); wrapper.appendChild(lbl);
} }
@ -1189,7 +1169,6 @@ function renderSeriesDetail(seriesName) {
<div class="cover-wrap"> <div class="cover-wrap">
<canvas class="cover-canvas"></canvas> <canvas class="cover-canvas"></canvas>
${statusBadge} ${statusBadge}
${b.archived ? `<div class="badge-archived" title="Archived"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg></div>` : ''}
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''} ${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''} ${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
</div> </div>
@ -1219,32 +1198,7 @@ function renderSeriesDetail(seriesName) {
grid.appendChild(wrapper); grid.appendChild(wrapper);
}); });
const allArchived = books.every(b => b.archived);
const header = document.createElement('div');
header.className = 'series-detail-header';
const archiveBtn = document.createElement('button');
archiveBtn.className = 'btn btn-sm';
archiveBtn.textContent = allArchived ? 'Unarchive series' : 'Archive series';
archiveBtn.onclick = async () => {
archiveBtn.disabled = true;
const res = await fetch('/library/archive-series', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({series: seriesName, archive: !allArchived}),
});
const data = await res.json();
if (data.ok) {
allBooks.forEach(b => { if (b.series === seriesName) b.archived = !allArchived; });
updateCounts();
renderSeriesDetail(seriesName);
} else {
archiveBtn.disabled = false;
}
};
header.appendChild(archiveBtn);
container.innerHTML = ''; container.innerHTML = '';
container.appendChild(header);
container.appendChild(grid); container.appendChild(grid);
} }
@ -1254,7 +1208,7 @@ function renderAuthorsView() {
const container = document.getElementById('grid-container'); const container = document.getElementById('grid-container');
const authorMap = {}; const authorMap = {};
for (const b of allBooks) { for (const b of activeBooks()) {
const a = bookAuthor(b); const a = bookAuthor(b);
if (!a) continue; if (!a) continue;
if (!authorMap[a]) authorMap[a] = []; if (!authorMap[a]) authorMap[a] = [];
@ -1297,7 +1251,7 @@ function renderPublishersView() {
const container = document.getElementById('grid-container'); const container = document.getElementById('grid-container');
const publisherMap = {}; const publisherMap = {};
for (const b of allBooks) { for (const b of activeBooks()) {
const key = bookPublisherKey(b); const key = bookPublisherKey(b);
if (!publisherMap[key]) publisherMap[key] = []; if (!publisherMap[key]) publisherMap[key] = [];
publisherMap[key].push(b); publisherMap[key].push(b);
@ -1543,7 +1497,6 @@ function renderDuplicatesView() {
</svg> </svg>
</button> </button>
${statusBadge} ${statusBadge}
${b.archived ? `<div class="badge-archived" title="Archived"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg></div>` : ''}
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''} ${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''} ${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
</div> </div>
@ -1591,8 +1544,6 @@ function renderAuthorDetail(authorName) {
const sa = a.series || '\uffff'; const sa = a.series || '\uffff';
const sb = b.series || '\uffff'; const sb = b.series || '\uffff';
if (sa !== sb) return sa.localeCompare(sb); if (sa !== sb) return sa.localeCompare(sb);
const va = a.series_volume || '', vb = b.series_volume || '';
if (va !== vb) return va.localeCompare(vb);
if (a.series_index !== b.series_index) return a.series_index - b.series_index; if (a.series_index !== b.series_index) return a.series_index - b.series_index;
if ((a.series_suffix || '') !== (b.series_suffix || '')) return (a.series_suffix || '').localeCompare(b.series_suffix || ''); if ((a.series_suffix || '') !== (b.series_suffix || '')) return (a.series_suffix || '').localeCompare(b.series_suffix || '');
return bookTitle(a).localeCompare(bookTitle(b)); return bookTitle(a).localeCompare(bookTitle(b));
@ -1608,8 +1559,6 @@ function renderPublisherDetail(publisherName) {
const sa = a.series || '\uffff'; const sa = a.series || '\uffff';
const sb = b.series || '\uffff'; const sb = b.series || '\uffff';
if (sa !== sb) return sa.localeCompare(sb); if (sa !== sb) return sa.localeCompare(sb);
const va = a.series_volume || '', vb = b.series_volume || '';
if (va !== vb) return va.localeCompare(vb);
if (a.series_index !== b.series_index) return a.series_index - b.series_index; if (a.series_index !== b.series_index) return a.series_index - b.series_index;
if ((a.series_suffix || '') !== (b.series_suffix || '')) return (a.series_suffix || '').localeCompare(b.series_suffix || ''); if ((a.series_suffix || '') !== (b.series_suffix || '')) return (a.series_suffix || '').localeCompare(b.series_suffix || '');
return bookTitle(a).localeCompare(bookTitle(b)); return bookTitle(a).localeCompare(bookTitle(b));

View File

@ -1,27 +1,3 @@
/* ── Develop banner ── */
.develop-banner {
position: fixed;
top: 26px;
left: -52px;
width: 200px;
background: rgba(194, 120, 20, 0.5);
color: rgba(255, 255, 255, 0.95);
text-align: center;
transform: rotate(-45deg);
z-index: 9999;
pointer-events: none;
}
.develop-banner-text {
display: block;
padding: 6px 0;
font-family: var(--mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.2em;
}
/* ── Sidebar ── */ /* ── Sidebar ── */
html { html {

View File

@ -1,7 +1,3 @@
{% if develop_mode() %}
<div class="develop-banner"><span class="develop-banner-text">DEVELOP</span></div>
{% endif %}
<button class="sidebar-toggle" id="sidebar-toggle" onclick="toggleSidebar()" aria-label="Menu"> <button class="sidebar-toggle" id="sidebar-toggle" onclick="toggleSidebar()" aria-label="Menu">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="3" y1="6" x2="21" y2="6"/> <line x1="3" y1="6" x2="21" y2="6"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Backup</title> <title>Novela - Backup</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — {{ title or filename }}</title> <title>Novela — {{ title or filename }}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
@ -31,9 +31,9 @@
</div> </div>
{% set r = (rating | default(0)) | int %} {% set r = (rating | default(0)) | int %}
<div class="star-row" id="book-stars"> <div class="star-row interactive" id="book-stars">
{% for i in range(1, 6) %} {% for i in range(1, 6) %}
<span class="star {% if i <= r %}filled{% endif %}"></span> <span class="star {% if i <= r %}filled{% endif %}" onclick="rateBook({{ i }})"></span>
{% endfor %} {% endfor %}
</div> </div>
@ -54,7 +54,7 @@
{% if series %} {% if series %}
<div class="meta-row"> <div class="meta-row">
<span class="meta-label">Series</span> <span class="meta-label">Series</span>
<span class="meta-value">{{ series }}{% if series_volume %} ({{ series_volume }}){% endif %}{% if series_index is defined and (series_index or series_suffix or series_is_indexed) %} [{{ series_index }}{{ series_suffix }}]{% endif %}</span> <span class="meta-value">{{ series }}{% if series_index is defined and (series_index or series_suffix or series_is_indexed) %} [{{ series_index }}{{ series_suffix }}]{% endif %}</span>
</div> </div>
{% endif %} {% endif %}
<div class="meta-row"> <div class="meta-row">
@ -254,13 +254,7 @@
</svg> </svg>
</button> </button>
</div> </div>
<div class="edit-field"> <div class="edit-field"><label class="edit-label">Title</label><input class="edit-input" id="ed-title" type="text"/></div>
<label class="edit-label">Title</label>
<div style="display:flex;gap:0.5rem;align-items:center">
<input class="edit-input" id="ed-title" type="text" style="flex:1"/>
<button type="button" class="btn-secondary" style="white-space:nowrap;padding:0.35rem 0.6rem;font-size:0.75rem" onclick="generateTitle()" title="Generate title from series info">Auto</button>
</div>
</div>
<div class="edit-field"> <div class="edit-field">
<label class="edit-label">Author</label> <label class="edit-label">Author</label>
<div class="genre-wrap"> <div class="genre-wrap">
@ -283,8 +277,7 @@
<div class="genre-dropdown" id="series-dropdown" style="display:none"></div> <div class="genre-dropdown" id="series-dropdown" style="display:none"></div>
</div> </div>
</div> </div>
<div class="edit-field"><label class="edit-label">Year/Volume</label><input class="edit-input" id="ed-series-volume" type="text" placeholder="e.g. 2024"/></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 class="edit-field"><label class="edit-label">Number</label><input class="edit-input" id="ed-series-index" type="text" placeholder="e.g. 1 or 21a"/></div>
</div> </div>
<div class="edit-field"> <div class="edit-field">
<label class="edit-label">Status</label> <label class="edit-label">Status</label>
@ -296,17 +289,6 @@
<option value="Long-Term Hold">Long-Term Hold</option> <option value="Long-Term Hold">Long-Term Hold</option>
</select> </select>
</div> </div>
<div class="edit-field">
<label class="edit-label">Rating</label>
<select class="edit-select" id="ed-rating">
<option value="0">— No rating</option>
<option value="1">★ 1</option>
<option value="2">★★ 2</option>
<option value="3">★★★ 3</option>
<option value="4">★★★★ 4</option>
<option value="5">★★★★★ 5</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> <div class="edit-field"><label class="edit-label">Source URL</label><input class="edit-input" id="ed-url" type="url" placeholder="https://…"/></div>
<div class="edit-field"><label class="edit-label">Description</label><textarea class="edit-input edit-textarea" id="ed-description" rows="5" placeholder="Story description…"></textarea></div> <div class="edit-field"><label class="edit-label">Description</label><textarea class="edit-input edit-textarea" id="ed-description" rows="5" placeholder="Story description…"></textarea></div>
<div class="edit-field"><label class="edit-label">Updated</label><input class="edit-input" id="ed-publish-date" type="date" style="color-scheme:dark"/></div> <div class="edit-field"><label class="edit-label">Updated</label><input class="edit-input" id="ed-publish-date" type="date" style="color-scheme:dark"/></div>
@ -386,7 +368,6 @@
series: {{ (series or '') | tojson }}, series: {{ (series or '') | tojson }},
series_index: {{ series_index or 0 }}, series_index: {{ series_index or 0 }},
series_suffix: {{ (series_suffix or '') | tojson }}, series_suffix: {{ (series_suffix or '') | tojson }},
series_volume: {{ (series_volume or '') | tojson }},
publication_status: {{ (publication_status or '') | tojson }}, publication_status: {{ (publication_status or '') | tojson }},
source_url: {{ (source_url or '') | tojson }}, source_url: {{ (source_url or '') | tojson }},
publish_date: {{ (publish_date or '') | tojson }}, publish_date: {{ (publish_date or '') | tojson }},

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Book Builder{% if view == 'editor' %}: {{ draft.title }}{% endif %}</title> <title>Novela — Book Builder{% if view == 'editor' %}: {{ draft.title }}{% endif %}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Bulk Import</title> <title>Novela Bulk Import</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
@ -189,7 +189,7 @@
td.td-skip input[type="checkbox"] { cursor: pointer; accent-color: var(--error); } 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 { background: rgba(200,90,58,0.06); }
tbody tr.row-dup:hover { background: rgba(200,90,58,0.10); } tbody tr.row-dup:hover { background: rgba(200,90,58,0.10); }
tbody tr.row-skipped { opacity: 0.38; } tbody tr.row-dup.row-skipped { opacity: 0.38; }
.cnt-dup { color: var(--error); } .cnt-dup { color: var(--error); }
.dup-actions { .dup-actions {
display: flex; gap: 0.5rem; align-items: center; display: flex; gap: 0.5rem; align-items: center;
@ -322,10 +322,6 @@
<div class="suggest-dropdown" id="series-dropdown" style="display:none"></div> <div class="suggest-dropdown" id="series-dropdown" style="display:none"></div>
</div> </div>
</div> </div>
<div>
<label>Year/Vol. <span style="color:var(--text-dim)">(series volume)</span></label>
<input type="text" id="shared-series-volume" autocomplete="off" oninput="updatePreview()" placeholder="e.g. 2024"/>
</div>
<div> <div>
<label>Status</label> <label>Status</label>
<select id="shared-status" oninput="updatePreview()"> <select id="shared-status" oninput="updatePreview()">
@ -344,14 +340,6 @@
<label>Tags <span style="color:var(--text-dim)">(comma-separated)</span></label> <label>Tags <span style="color:var(--text-dim)">(comma-separated)</span></label>
<input type="text" id="shared-tags"/> <input type="text" id="shared-tags"/>
<label style="display:flex;align-items:center;gap:0.5rem;margin-top:0.5rem;cursor:pointer">
<input type="checkbox" id="auto-title" oninput="updatePreview()"/>
Auto-generate titles from series info &mdash; <span style="color:var(--text-dim)">for comics without individual titles</span>
</label>
<p class="hint" id="auto-title-hint" style="display:none">
Format: <code>Series (Year/Vol) #Number</code>. Only fills in rows where title is empty or matches the filename stem.
</p>
</div> </div>
<!-- Card 3: Files --> <!-- Card 3: Files -->
@ -385,13 +373,12 @@
<th style="width:2.5rem">#</th> <th style="width:2.5rem">#</th>
<th>Filename</th> <th>Filename</th>
<th>Series</th> <th>Series</th>
<th>Yr/Vol</th>
<th>Vol</th> <th>Vol</th>
<th>Title</th> <th>Title</th>
<th>Author</th> <th>Author</th>
<th>Publisher</th> <th>Publisher</th>
<th>Year</th> <th>Year</th>
<th style="width:2.5rem" title="Skip this file during import">Skip</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> <th style="width:1.5rem"></th>
</tr> </tr>
</thead> </thead>
@ -446,7 +433,6 @@
// ── State ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
const PLACEHOLDER_META = [ const PLACEHOLDER_META = [
{ key: 'series', label: '%series%', color: 'var(--accent)' }, { key: 'series', label: '%series%', color: 'var(--accent)' },
{ key: 'series_volume', label: '%series_volume%', color: '#d07840' },
{ key: 'volume', label: '%volume%', color: '#4a90b8' }, { key: 'volume', label: '%volume%', color: '#4a90b8' },
{ key: 'title', label: '%title%', color: 'var(--success)' }, { key: 'title', label: '%title%', color: 'var(--success)' },
{ key: 'year', label: '%year%', color: 'var(--warning)' }, { key: 'year', label: '%year%', color: 'var(--warning)' },
@ -458,7 +444,7 @@
]; ];
let selectedFiles = []; let selectedFiles = [];
let parsedRows = []; // [{original_filename, series, series_volume, volume, title, year, author, publisher, status, genres, tags, _warn}] let parsedRows = []; // [{original_filename, series, volume, title, year, author, publisher, status, genres, tags, _warn}]
const BATCH_SIZE = 5; const BATCH_SIZE = 5;
@ -602,30 +588,14 @@
const pattern = document.getElementById('pattern-input').value; const pattern = document.getElementById('pattern-input').value;
const sharedSeries = document.getElementById('shared-series').value.trim(); const sharedSeries = document.getElementById('shared-series').value.trim();
const sharedSeriesVolume = document.getElementById('shared-series-volume').value.trim();
const autoTitle = document.getElementById('auto-title').checked;
document.getElementById('auto-title-hint').style.display = autoTitle ? '' : 'none';
parsedRows = selectedFiles.map(f => { parsedRows = selectedFiles.map(f => {
const stem = f.name.replace(/\.[^.]+$/, ''); const stem = f.name.replace(/\.[^.]+$/, '');
const parsed = parseFilename(stem, pattern); const parsed = parseFilename(stem, pattern);
const series = sharedSeries || parsed.series || '';
const series_volume = sharedSeriesVolume || parsed.series_volume || '';
const volume = parsed.volume || '';
let title = parsed.title || '';
if (autoTitle && series && !title) {
title = series;
if (series_volume) title += ` (${series_volume})`;
if (volume) title += ` #${volume}`;
}
return { return {
original_filename: f.name, original_filename: f.name,
series, series: sharedSeries || parsed.series || '',
series_volume, volume: parsed.volume || '',
volume, title: parsed.title || stem,
title: title || stem,
year: parsed.year || '', year: parsed.year || '',
author: parsed.author || '', author: parsed.author || '',
publisher: parsed.publisher || '', publisher: parsed.publisher || '',
@ -648,7 +618,6 @@
const items = parsedRows.map(r => ({ const items = parsedRows.map(r => ({
title: r.title, title: r.title,
author: r.author || sharedAuthor, author: r.author || sharedAuthor,
series: r.series,
volume: r.volume, volume: r.volume,
})); }));
try { try {
@ -677,6 +646,8 @@
tbody.innerHTML = ''; tbody.innerHTML = '';
const hasDups = parsedRows.some(r => r._duplicate); const hasDups = parsedRows.some(r => r._duplicate);
const thSkip = document.getElementById('th-skip');
if (thSkip) thSkip.style.display = hasDups ? '' : 'none';
let warnCount = 0; let warnCount = 0;
let dupCount = 0; let dupCount = 0;
@ -691,7 +662,7 @@
const classes = []; const classes = [];
if (row._warn) classes.push('row-warn'); if (row._warn) classes.push('row-warn');
if (row._duplicate) classes.push('row-dup'); if (row._duplicate) classes.push('row-dup');
if (row._skip) classes.push('row-skipped'); if (row._duplicate && row._skip) classes.push('row-skipped');
if (classes.length) tr.className = classes.join(' '); if (classes.length) tr.className = classes.join(' ');
// # // #
@ -708,10 +679,8 @@
tr.appendChild(tdFn); tr.appendChild(tdFn);
// Editable fields // Editable fields
const sharedSeriesVolume = document.getElementById('shared-series-volume').value.trim();
const fields = [ const fields = [
{ key: 'series', placeholder: '—' }, { key: 'series', placeholder: '—' },
{ key: 'series_volume', placeholder: sharedSeriesVolume || '—' },
{ key: 'volume', placeholder: '—' }, { key: 'volume', placeholder: '—' },
{ key: 'title', placeholder: 'Title' }, { key: 'title', placeholder: 'Title' },
{ key: 'author', placeholder: sharedAuthor || '—' }, { key: 'author', placeholder: sharedAuthor || '—' },
@ -740,9 +709,11 @@
tr.appendChild(td); tr.appendChild(td);
}); });
// Skip checkbox (always visible) // Skip checkbox (only shown when duplicates exist)
const tdSkip = document.createElement('td'); const tdSkip = document.createElement('td');
tdSkip.className = 'td-skip'; tdSkip.className = 'td-skip';
tdSkip.style.display = hasDups ? '' : 'none';
if (row._duplicate) {
const cb = document.createElement('input'); const cb = document.createElement('input');
cb.type = 'checkbox'; cb.type = 'checkbox';
cb.checked = row._skip; cb.checked = row._skip;
@ -753,6 +724,7 @@
renderPreviewStats(); renderPreviewStats();
}); });
tdSkip.appendChild(cb); tdSkip.appendChild(cb);
}
tr.appendChild(tdSkip); tr.appendChild(tdSkip);
// Warning indicator // Warning indicator
@ -778,9 +750,14 @@
const statsEl = document.getElementById('preview-stats'); const statsEl = document.getElementById('preview-stats');
let stats = `<span class="cnt-ok">${importCount} to import</span>`; let stats = `<span class="cnt-ok">${importCount} to import</span>`;
if (skipCount) stats += ` &nbsp; <span class="cnt-warn">${skipCount} skipped</span>`;
if (warnCount) stats += ` &nbsp; <span class="cnt-warn">${warnCount} to check</span>`; if (warnCount) stats += ` &nbsp; <span class="cnt-warn">${warnCount} to check</span>`;
if (dupCount) stats += ` &nbsp; <span class="cnt-dup">${dupCount} duplicate${dupCount !== 1 ? 's' : ''}</span>`; if (dupCount) {
stats += ` &nbsp; <span class="cnt-dup">${dupCount} duplicate${dupCount !== 1 ? 's' : ''}</span>`;
stats += ` &nbsp; <span class="dup-actions">`;
stats += `<button onclick="setAllDuplicatesSkip(true)">Skip all</button>`;
stats += `<button onclick="setAllDuplicatesSkip(false)">Import all</button>`;
stats += `</span>`;
}
statsEl.innerHTML = stats; statsEl.innerHTML = stats;
document.getElementById('import-btn-label').textContent = document.getElementById('import-btn-label').textContent =
@ -800,7 +777,6 @@
author: document.getElementById('shared-author').value.trim(), author: document.getElementById('shared-author').value.trim(),
publisher: document.getElementById('shared-publisher').value.trim(), publisher: document.getElementById('shared-publisher').value.trim(),
series: document.getElementById('shared-series').value.trim(), series: document.getElementById('shared-series').value.trim(),
series_volume: document.getElementById('shared-series-volume').value.trim(),
status: document.getElementById('shared-status').value, status: document.getElementById('shared-status').value,
genres: document.getElementById('shared-genres').value.trim(), genres: document.getElementById('shared-genres').value.trim(),
tags: document.getElementById('shared-tags').value.trim(), tags: document.getElementById('shared-tags').value.trim(),

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Changelog</title> <title>Novela — Changelog</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Credentials</title> <title>Novela — Credentials</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Debug</title> <title>Novela — Debug</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Edit {{ title or filename }}</title> <title>Novela — Edit {{ title or filename }}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Following</title> <title>Novela — Following</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %}</title> <title>Novela</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Home</title> <title>Novela — Home</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %}</title> <title>Novela</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Library</title> <title>Novela — Library</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — {{ title }}</title> <title>Novela — {{ title }}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
@ -69,16 +69,6 @@
.btn-header-read:hover { background: rgba(107,170,107,0.08); border-color: var(--success); } .btn-header-read:hover { background: rgba(107,170,107,0.08); border-color: var(--success); }
.btn-header-bm { color: var(--accent); border-color: rgba(255,162,14,0.3); } .btn-header-bm { 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); } .btn-header-bm:hover { background: rgba(255,162,14,0.08); border-color: var(--accent); }
.btn-header-series {
display: none;
color: var(--text-faint); border-color: rgba(255,255,255,0.08);
padding: 0.3rem 0.5rem;
}
.btn-header-series.active {
display: flex;
color: var(--text-dim); border-color: var(--border);
}
.btn-header-series.active:hover { color: var(--text); border-color: var(--text-faint); }
/* ── Bookmark modal ── */ /* ── Bookmark modal ── */
.bm-overlay { .bm-overlay {
@ -197,7 +187,7 @@
/* Chapter content */ /* Chapter content */
#chapter-content { #chapter-content {
font-family: var(--serif); font-family: var(--serif);
font-size: var(--reader-font-size, 1.05rem); font-size: 1.05rem;
line-height: 1.85; line-height: 1.85;
color: var(--text); color: var(--text);
} }
@ -320,14 +310,6 @@
<input type="range" id="width-slider" min="30" max="100" step="1" <input type="range" id="width-slider" min="30" max="100" step="1"
value="65" oninput="applyWidth(this.value)"/> value="65" oninput="applyWidth(this.value)"/>
</div> </div>
<div class="settings-row">
<div class="settings-label">
Font size
<span id="fontsize-value">105%</span>
</div>
<input type="range" id="fontsize-slider" min="80" max="150" step="1"
value="105" oninput="applyFontSize(this.value)"/>
</div>
<div class="settings-row"> <div class="settings-row">
<div class="settings-label">Text colour</div> <div class="settings-label">Text colour</div>
<div class="colour-swatches"> <div class="colour-swatches">
@ -357,18 +339,6 @@
</a> </a>
<div class="header-title" id="header-title"></div> <div class="header-title" id="header-title"></div>
<div class="header-actions"> <div class="header-actions">
<button class="btn-header btn-header-series" id="btn-series-prev" title="" onclick="goSeriesPrev()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="19 20 9 12 19 4"/>
<line x1="5" y1="4" x2="5" y2="20"/>
</svg>
</button>
<button class="btn-header btn-header-series" id="btn-series-next" title="" onclick="goSeriesNext()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="5 4 15 12 5 20"/>
<line x1="19" y1="4" x2="19" y2="20"/>
</svg>
</button>
<button class="btn-header btn-header-bm" onclick="openBookmarkModal()" title="Add bookmark at current position"> <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"> <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"/> <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
@ -421,7 +391,6 @@
let currentIndex = 0; let currentIndex = 0;
let saveTimer = null; let saveTimer = null;
let scrollTimer = null; let scrollTimer = null;
let seriesNav = { prev: null, next: null };
// ── Width setting ────────────────────────────────────────────── // ── Width setting ──────────────────────────────────────────────
function applyWidth(pct) { function applyWidth(pct) {
@ -451,20 +420,6 @@
applyTextColour(saved); applyTextColour(saved);
} }
// ── Font size ──────────────────────────────────────────────────
function applyFontSize(pct) {
const val = parseInt(pct, 10);
document.documentElement.style.setProperty('--reader-font-size', (val / 100) + 'rem');
document.getElementById('fontsize-value').textContent = val + '%';
document.getElementById('fontsize-slider').value = val;
localStorage.setItem('reader-font-size', val);
}
function loadFontSize() {
const saved = parseInt(localStorage.getItem('reader-font-size') || '105', 10);
applyFontSize(saved);
}
// ── Settings drawer ──────────────────────────────────────────── // ── Settings drawer ────────────────────────────────────────────
function toggleSettings() { function toggleSettings() {
const open = document.getElementById('settings-drawer').classList.toggle('open'); const open = document.getElementById('settings-drawer').classList.toggle('open');
@ -625,8 +580,6 @@
async function init() { async function init() {
loadWidth(); loadWidth();
loadTextColour(); loadTextColour();
loadFontSize();
loadSeriesNav();
const progResp = await fetch(`/library/progress/${encodeURIComponent(filename)}`); const progResp = await fetch(`/library/progress/${encodeURIComponent(filename)}`);
const prog = await progResp.json(); const prog = await progResp.json();
@ -673,45 +626,11 @@
document.getElementById('loading').style.display = 'none'; document.getElementById('loading').style.display = 'none';
} }
// ── Series navigation ──────────────────────────────────────────
async function loadSeriesNav() {
try {
const resp = await fetch(`/api/series-nav/${encodeURIComponent(filename)}`);
seriesNav = await resp.json();
} catch { return; }
const btnPrev = document.getElementById('btn-series-prev');
const btnNext = document.getElementById('btn-series-next');
if (seriesNav.prev) {
const label = seriesNav.prev.index ? `#${seriesNav.prev.index}${seriesNav.prev.suffix} ${seriesNav.prev.title}` : seriesNav.prev.title;
btnPrev.title = label;
btnPrev.classList.add('active');
}
if (seriesNav.next) {
const label = seriesNav.next.index ? `#${seriesNav.next.index}${seriesNav.next.suffix} ${seriesNav.next.title}` : seriesNav.next.title;
btnNext.title = label;
btnNext.classList.add('active');
}
}
function goSeriesPrev() {
if (seriesNav.prev) window.location.href = `/library/read/${encodeURIComponent(seriesNav.prev.filename)}`;
}
function goSeriesNext() {
if (seriesNav.next) window.location.href = `/library/read/${encodeURIComponent(seriesNav.next.filename)}`;
}
async function markRead() { async function markRead() {
clearTimeout(saveTimer); clearTimeout(saveTimer);
await fetch(`/library/mark-read/${encodeURIComponent(filename)}`, { method: 'POST' }); await fetch(`/library/mark-read/${encodeURIComponent(filename)}`, { method: 'POST' });
if (seriesNav.next) {
window.location.href = `/library/read/${encodeURIComponent(seriesNav.next.filename)}`;
} else {
window.location.href = `/library/book/${encodeURIComponent(filename)}`; window.location.href = `/library/book/${encodeURIComponent(filename)}`;
} }
}
// ── Bookmarks ────────────────────────────────────────────────── // ── Bookmarks ──────────────────────────────────────────────────
function openBookmarkModal() { function openBookmarkModal() {

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Search</title> <title>Novela — Search</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
@ -19,18 +19,7 @@
.page-title { font-size: 1.4rem; font-weight: 700; margin-bottom: 1.5rem; } .page-title { font-size: 1.4rem; font-weight: 700; margin-bottom: 1.5rem; }
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; } .search-bar { display: flex; gap: 0.5rem; margin-bottom: 2rem; }
.search-mode { display: flex; gap: 0; margin-bottom: 0.5rem; }
.search-filter { display: flex; gap: 0; margin-bottom: 2rem; }
.mode-btn {
padding: 0.35rem 0.85rem; font-size: 0.8rem; cursor: pointer;
border: 1px solid var(--border); background: var(--surface); color: var(--text-dim);
font-family: var(--mono);
}
.mode-btn:first-child { border-radius: var(--radius) 0 0 var(--radius); }
.mode-btn:last-child { border-radius: 0 var(--radius) var(--radius) 0; border-left: none; }
.mode-btn + .mode-btn { border-left: none; }
.mode-btn.active { background: var(--surface2); color: var(--text); border-color: var(--accent); z-index: 1; }
.search-input { .search-input {
flex: 1; padding: 0.55rem 0.8rem; border-radius: var(--radius); flex: 1; padding: 0.55rem 0.8rem; border-radius: var(--radius);
border: 1px solid var(--border); background: var(--surface); color: var(--text); border: 1px solid var(--border); background: var(--surface); color: var(--text);
@ -95,15 +84,6 @@
placeholder="Search inside books…" autocomplete="off"/> placeholder="Search inside books…" autocomplete="off"/>
<button class="search-btn" onclick="doSearch()">Search</button> <button class="search-btn" onclick="doSearch()">Search</button>
</div> </div>
<div class="search-mode">
<button class="mode-btn active" id="mode-phrase" onclick="setMode('phrase')">Phrase</button>
<button class="mode-btn" id="mode-words" onclick="setMode('words')">All words</button>
</div>
<div class="search-filter">
<button class="mode-btn active" id="filter-all" onclick="setFilter('all')">All</button>
<button class="mode-btn" id="filter-unread_novels" onclick="setFilter('unread_novels')">Unread novels</button>
<button class="mode-btn" id="filter-unread_shorts" onclick="setFilter('unread_shorts')">Unread shorts</button>
</div>
<div class="search-status" id="search-status"></div> <div class="search-status" id="search-status"></div>
<div class="result-list" id="result-list"></div> <div class="result-list" id="result-list"></div>
@ -113,41 +93,21 @@
const input = document.getElementById('search-input'); const input = document.getElementById('search-input');
const statusEl = document.getElementById('search-status'); const statusEl = document.getElementById('search-status');
const listEl = document.getElementById('result-list'); const listEl = document.getElementById('result-list');
let searchMode = 'phrase';
let searchFilter = 'all';
function setMode(mode) {
searchMode = mode;
document.getElementById('mode-phrase').classList.toggle('active', mode === 'phrase');
document.getElementById('mode-words').classList.toggle('active', mode === 'words');
}
function setFilter(filter) {
searchFilter = filter;
document.getElementById('filter-all').classList.toggle('active', filter === 'all');
document.getElementById('filter-unread_novels').classList.toggle('active', filter === 'unread_novels');
document.getElementById('filter-unread_shorts').classList.toggle('active', filter === 'unread_shorts');
}
input.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); input.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
// Auto-run if ?q= param provided // Auto-run if ?q= param provided
const urlParams = new URLSearchParams(location.search); const urlQ = new URLSearchParams(location.search).get('q');
const urlQ = urlParams.get('q');
const urlMode = urlParams.get('mode');
const urlFilter = urlParams.get('filter');
if (urlMode === 'words') setMode('words');
if (urlFilter === 'unread_novels' || urlFilter === 'unread_shorts') setFilter(urlFilter);
if (urlQ) { input.value = urlQ; doSearch(); } if (urlQ) { input.value = urlQ; doSearch(); }
async function doSearch() { async function doSearch() {
const q = input.value.trim(); const q = input.value.trim();
if (!q) { listEl.innerHTML = ''; statusEl.textContent = ''; return; } if (!q) { listEl.innerHTML = ''; statusEl.textContent = ''; return; }
history.replaceState(null, '', '/search?q=' + encodeURIComponent(q) + '&mode=' + searchMode + '&filter=' + searchFilter); history.replaceState(null, '', '/search?q=' + encodeURIComponent(q));
statusEl.textContent = 'Searching…'; statusEl.textContent = 'Searching…';
listEl.innerHTML = ''; listEl.innerHTML = '';
try { try {
const resp = await fetch('/api/search?q=' + encodeURIComponent(q) + '&mode=' + searchMode + '&filter=' + searchFilter); const resp = await fetch('/api/search?q=' + encodeURIComponent(q));
if (!resp.ok) throw new Error('Search failed'); if (!resp.ok) throw new Error('Search failed');
const results = await resp.json(); const results = await resp.json();
render(results, q); render(results, q);

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Settings</title> <title>Novela — Settings</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
@ -169,22 +169,6 @@
<main class="main"> <main class="main">
<div class="main-title">Settings</div> <div class="main-title">Settings</div>
<!-- Develop mode -->
<div class="card">
<div class="card-title">Develop mode</div>
<div class="card-desc">
Mark this as a development instance. Adds a <strong>DEVELOP</strong> banner to every page
and shows <strong>Novela Develop</strong> in the browser tab title.
</div>
<label style="display:flex;align-items:center;gap:0.75rem;cursor:pointer">
<input type="checkbox" id="develop-mode-toggle" style="width:16px;height:16px;accent-color:var(--accent);cursor:pointer"
{% if develop_mode() %}checked{% endif %}
onchange="toggleDevelopMode(this.checked)"/>
<span style="font-family:var(--mono);font-size:0.8rem">Enable develop mode</span>
</label>
<div class="feedback" id="develop-feedback"></div>
</div>
<!-- Reading history reset --> <!-- Reading history reset -->
<div class="card"> <div class="card">
<div class="card-title">Reading history</div> <div class="card-title">Reading history</div>
@ -204,30 +188,6 @@
</button> </button>
<div class="feedback" id="reset-feedback"></div> <div class="feedback" id="reset-feedback"></div>
</div> </div>
<!-- Break image -->
<div class="card">
<div class="card-title">Break image</div>
<div class="card-desc">
The image used as a scene break in converted books.
Applies to all newly imported books. Upload a PNG, JPG or WebP image.
</div>
<div id="break-image-preview" style="margin-bottom:1rem;display:none">
<img id="break-image-img" src="" alt="break image"
style="max-height:40px;border:1px solid var(--border);border-radius:4px;padding:4px;background:var(--surface)"/>
</div>
<label class="btn" style="background:var(--surface);border:1px solid var(--border);color:var(--text);cursor:pointer">
<svg width="13" height="13" 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="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Upload break image
<input type="file" id="break-image-file" accept="image/png,image/jpeg,image/webp"
style="display:none" onchange="uploadBreakImage(this)"/>
</label>
<div class="feedback" id="break-image-feedback"></div>
</div>
<!-- Break detection patterns --> <!-- Break detection patterns -->
<div class="card"> <div class="card">
<div class="card-title">Break detection</div> <div class="card-title">Break detection</div>
@ -296,26 +256,6 @@
<script src="/static/books.js"></script> <script src="/static/books.js"></script>
<script> <script>
// ── Develop mode ───────────────────────────────────────────────────────────
async function toggleDevelopMode(enabled) {
const fb = document.getElementById('develop-feedback');
fb.className = 'feedback';
fb.textContent = '';
const resp = await fetch('/api/app-settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ develop_mode: enabled }),
});
const data = await resp.json();
if (data.ok) {
location.reload();
} else {
fb.className = 'feedback err';
fb.textContent = 'Could not save setting.';
document.getElementById('develop-mode-toggle').checked = !enabled;
}
}
// ── Break patterns ───────────────────────────────────────────────────────── // ── Break patterns ─────────────────────────────────────────────────────────
let bpPatterns = []; let bpPatterns = [];
@ -443,45 +383,12 @@
// Enter key in add inputs // Enter key in add inputs
// ── Break image ────────────────────────────────────────────────────────────
async function loadBreakImagePreview() {
const resp = await fetch('/api/app-settings');
const data = await resp.json();
if (data.break_image_url) {
document.getElementById('break-image-img').src = data.break_image_url + '?t=' + Date.now();
document.getElementById('break-image-preview').style.display = 'block';
}
}
async function uploadBreakImage(input) {
const file = input.files[0];
if (!file) return;
const fb = document.getElementById('break-image-feedback');
fb.className = 'feedback';
fb.textContent = 'Uploading…';
const form = new FormData();
form.append('file', file);
const resp = await fetch('/api/app-settings/break-image', { method: 'POST', body: form });
const data = await resp.json();
input.value = '';
if (data.ok) {
fb.className = 'feedback ok';
fb.textContent = 'Break image updated.';
document.getElementById('break-image-img').src = data.url + '?t=' + Date.now();
document.getElementById('break-image-preview').style.display = 'block';
} else {
fb.className = 'feedback err';
fb.textContent = data.error || 'Upload failed.';
}
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('bp-text-input').addEventListener('keydown', e => { if (e.key === 'Enter') addPatternFromText(); }); document.getElementById('bp-text-input').addEventListener('keydown', e => { if (e.key === 'Enter') addPatternFromText(); });
document.getElementById('bp-regex-input').addEventListener('keydown', e => { if (e.key === 'Enter') addPattern('regex'); }); document.getElementById('bp-regex-input').addEventListener('keydown', e => { if (e.key === 'Enter') addPattern('regex'); });
document.getElementById('bp-css-input').addEventListener('keydown', e => { if (e.key === 'Enter') addPattern('css_class'); }); document.getElementById('bp-css-input').addEventListener('keydown', e => { if (e.key === 'Enter') addPattern('css_class'); });
document.getElementById('bp-test-input').addEventListener('keydown', e => { if (e.key === 'Enter') testBreak(); }); document.getElementById('bp-test-input').addEventListener('keydown', e => { if (e.key === 'Enter') testBreak(); });
loadBreakPatterns(); loadBreakPatterns();
loadBreakImagePreview();
}); });
// ── Reading history ──────────────────────────────────────────────────────── // ── Reading history ────────────────────────────────────────────────────────

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Statistics</title> <title>Novela — Statistics</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/> <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="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/> <link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>

View File

@ -1,7 +1,7 @@
import re import re
from html import escape as he from html import escape as he
from bs4 import Comment, NavigableString, Tag from bs4 import NavigableString, Tag
BREAK_PATTERNS = [ BREAK_PATTERNS = [
re.compile(r"^\s*[\*\-]{3,}\s*$"), # *** of --- re.compile(r"^\s*[\*\-]{3,}\s*$"), # *** of ---
@ -111,12 +111,6 @@ def element_to_xhtml(el, break_img_path: str = "../Images/break.png", empty_p_is
result += "\n" + trailer result += "\n" + trailer
return result return result
if isinstance(el, Comment):
# Preserve HTML comments as XML comments (e.g. scraper metadata).
# Sanitise "--" sequences which are illegal inside XML comments.
body = str(el).replace("--", "- -")
return f"<!-- {body} -->"
if isinstance(el, NavigableString): if isinstance(el, NavigableString):
text = str(el) text = str(el)
if text.strip(): if text.strip():

View File

@ -6,7 +6,6 @@ It is the primary technical reference for the current implementation.
## Architecture ## Architecture
- Stack: FastAPI, Jinja2 templates, plain JavaScript, PostgreSQL 16, Docker. - Stack: FastAPI, Jinja2 templates, plain JavaScript, PostgreSQL 16, Docker.
- All routers import `templates` from `shared_templates.py` (a single `Jinja2Templates` instance). This module registers a `develop_mode()` callable as a Jinja2 global, making it available in every template without passing it explicitly per route.
- Startup lifecycle (`main.py`): - Startup lifecycle (`main.py`):
1. `init_pool()` 1. `init_pool()`
2. `run_migrations()` 2. `run_migrations()`
@ -27,23 +26,22 @@ All files are stored under `library/` (relative to the app working directory, ma
| Format | Path pattern | | Format | Path pattern |
|--------|-------------| |--------|-------------|
| EPUB (no series) | `library/epub/{publisher}/{author}/Stories/{title}.epub` | | EPUB (no series) | `library/epub/{publisher}/{author}/Stories/{title}.epub` |
| EPUB (series) | `library/epub/{publisher}/{author}/Series/{series}/{idx:03d}_-_{title}.epub` | | EPUB (series) | `library/epub/{publisher}/{author}/Series/{series}/{idx:03d} - {title}.epub` |
| PDF | `library/pdf/{publisher}/{author}/{title}.pdf` | | PDF | `library/pdf/{publisher}/{author}/{title}.pdf` |
| CBR (no series) | `library/comics/{publisher}/{author}/{title}.cbr` | | CBR (no series) | `library/comics/{publisher}/{author}/{title}.cbr` |
| CBR (series) | `library/comics/{publisher}/{author}/Series/{series}/{idx:03d}_-_{title}.cbr` | | CBR (series) | `library/comics/{publisher}/{author}/Series/{series}/{idx:03d} - {title}.cbr` |
| CBZ (no series) | `library/comics/{publisher}/{author}/{title}.cbz` | | CBZ (no series) | `library/comics/{publisher}/{author}/{title}.cbz` |
| CBZ (series) | `library/comics/{publisher}/{author}/Series/{series}/{idx:03d}_-_{title}.cbz` | | CBZ (series) | `library/comics/{publisher}/{author}/Series/{series}/{idx:03d} - {title}.cbz` |
- Segments are sanitised: special chars stripped, spaces replaced with `_`, max lengths applied (publisher/author 80, title 140, series 80). - Segments are sanitised: special chars stripped, max lengths applied (publisher/author 80, title 140, series 80).
- Series index is zero-padded to 3 digits (`001`, `002`, …), clamped to 1999. - Series index is zero-padded to 3 digits (`001`, `002`, …), clamped to 1999.
- Duplicate filenames get a `(2)`, `(3)`, … suffix. - Duplicate filenames get a `(2)`, `(3)`, … suffix.
- After any file move, empty parent directories are pruned up to `LIBRARY_ROOT`. - After any file move, empty parent directories are pruned up to `LIBRARY_ROOT`.
### Path logic ### Path logic
- `common.make_rel_path(media_type, publisher, author, title, series, series_index, series_suffix, ext)` — used by import and grabber. - `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, series_suffix, ext)` — used by metadata PATCH; same logic, uses actual file extension. - `reader.py _make_rel_path(publisher, author, title, series, series_index, ext)` — used by metadata PATCH; same logic, uses actual file extension.
- `series_volume` is not part of the file path; it is stored in DB and OPF only.
- Both functions produce identical paths for all formats. - Both functions produce identical paths for all formats.
### Metadata save behaviour per format ### Metadata save behaviour per format
@ -68,10 +66,9 @@ All files are stored under `library/` (relative to the app working directory, ma
- `GET /download/{filename}` — download file with `Content-Disposition: attachment` - `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/{filename}` — serve cover (EPUB from file; PDF/CBR from cache)
- `GET /library/cover-cached/{filename}` — serve cover from DB cache only - `GET /library/cover-cached/{filename}` — serve cover from DB cache only
- `POST /library/cover/{filename}` — upload/replace cover; for EPUB files: embeds cover in the EPUB and updates cache; for DB-stored books: stores cover directly in `library_cover_cache` and sets `has_cover = TRUE` - `POST /library/cover/{filename}` — upload/replace cover (EPUB only)
- `POST /library/want-to-read/{filename}` — toggle want-to-read flag - `POST /library/want-to-read/{filename}` — toggle want-to-read flag
- `POST /library/archive/{filename}` — toggle archived flag - `POST /library/archive/{filename}` — toggle archived flag
- `POST /library/archive-series` — set `archived` for all books in a series; body: `{"series": "…", "archive": true|false}`; returns `{ok, archived, count}`
- `POST /library/new/mark-reviewed` — bulk set `needs_review=false` - `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/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}` - `POST /library/rating/{filename}` — set/clear star rating `{"rating": 0-5}`
@ -80,7 +77,7 @@ All files are stored under `library/` (relative to the app working directory, ma
- `GET /stats` — statistics page - `GET /stats` — statistics page
- `GET /api/stats` — statistics data JSON - `GET /api/stats` — statistics data JSON
- `GET /api/disk` — partition usage for the library directory: `{total, used, free, pct_used}` - `GET /api/disk` — partition usage for the library directory: `{total, used, free, pct_used}`
- `POST /api/bulk-check-duplicates` — accepts `{"items": [{title, author, series, volume}, ...]}`, returns `{"duplicates": [bool, ...]}`checks by title+author+series_index; also checks by series+author+series_index as fallback (catches duplicate detection when title format changed); when volume is absent, matches on title+author only - `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 /library/list` — compat alias
`GET /api/library` runs in fast-path mode by default (DB-only, no full disk rescan). `GET /api/library` runs in fast-path mode by default (DB-only, no full disk rescan).
@ -128,7 +125,6 @@ Home read sections are ordered oldest-first:
- `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 - `PATCH /library/book/{filename}` — update metadata + tags; moves file if path fields change; DB-only for non-EPUB; for `storage_type='db'` books: recomputes synthetic `db/…` filename, FK-safe rename (INSERT→UPDATE children→DELETE old), updates `book_chapters` + `bookmarks` as well
- `POST /library/rating/{filename}` — set/clear 15 star rating; writes to EPUB OPF / CBZ ComicInfo.xml; DB-only for CBR/PDF - `POST /library/rating/{filename}` — set/clear 15 star rating; writes to EPUB OPF / CBZ ComicInfo.xml; DB-only for CBR/PDF
- `GET /library/read/{filename}` — reader page (EPUB or PDF); supports `?bm_ch=N&bm_scroll=F` to jump to bookmark position - `GET /library/read/{filename}` — reader page (EPUB or PDF); supports `?bm_ch=N&bm_scroll=F` to jump to bookmark position
- `GET /api/series-nav/{filename}` — returns `{prev, next}` (`{filename, title, index, suffix}` or `null`) for the adjacent books in the same series ordered by `series_index ASC, series_suffix ASC`; used by the reader for series navigation buttons and `markRead()` redirect
- `GET /library/bookmarks/{filename}` — list bookmarks for a book - `GET /library/bookmarks/{filename}` — list bookmarks for a book
- `POST /library/bookmarks/{filename}` — add bookmark `{chapter_index, scroll_frac, chapter_title, note}` - `POST /library/bookmarks/{filename}` — add bookmark `{chapter_index, scroll_frac, chapter_title, note}`
- `PATCH /library/bookmarks/{id}` — update bookmark note - `PATCH /library/bookmarks/{id}` — update bookmark note
@ -139,7 +135,7 @@ Home read sections are ordered oldest-first:
- `GET /bulk-import` — Bulk Import page - `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) - `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% - %series_volume% - %volume% - %title% - %year%`). Available placeholders: `%series%` `%series_volume%` `%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 (including "Year/Vol." for `series_volume`) override filename-parsed values. "Auto-generate titles" checkbox fills empty title cells as `Series (Year/Vol) #Number`. Skip checkbox is always visible for every row; skipped rows are excluded from import. Files are uploaded in batches of 5 with a progress bar. Filename parsing is done client-side in `bulk_import.html`. The page uses a free-text `%placeholder%` pattern (e.g. `%series% - %volume% - %title% - %year%`). Available placeholders: `%series%` `%volume%` `%title%` `%year%` `%month%` `%day%` `%author%` `%publisher%` `%ignore%`. Colored chips can be clicked (insert at cursor) or dragged onto the input. Pattern is converted to a regex at parse time. Shared metadata fields override filename-parsed values. Files are uploaded in batches of 5 with a progress bar.
### `routers/editor.py` ### `routers/editor.py`
- `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 /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)
@ -163,130 +159,27 @@ Filename parsing is done client-side in `bulk_import.html`. The page uses a free
Scrape/convert flow (DB storage — default): Scrape/convert flow (DB storage — default):
1. Fetch book info + chapters via scraper 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/...`; break images replaced with `<hr>` before `element_to_xhtml` runs → build `content_html` via `element_to_xhtml` with `break_img_path="/static/break.png"` 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 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) 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"`): Scrape/convert flow (EPUB file — `storage_mode: "epub"`):
12. Same as DB flow; `break_img_path="../Images/break.png"` passed to `element_to_xhtml` 12. Same as DB flow (images downloaded, HTML built)
3. Chapters converted to XHTML via `make_chapter_xhtml`; EPUB file built via `make_epub` (embeds `static/break.png` as `OEBPS/Images/break.png`) and written to `library/epub/…` 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'` 4. `upsert_book` called with `storage_type='file'`
### Scrapers (`scrapers/`)
All scrapers inherit `BaseScraper` and implement `matches(url)`, `login()`, `fetch_book_info()`, `fetch_chapter()`. Registration order in `scrapers/__init__.py` determines priority (first match wins).
| Scraper | Domain | Login | Notes |
|---|---|---|---|
| `ArchiveOfOurOwnScraper` | archiveofourown.org | Optional | Uses authenticity token; adult content gate via `?view_adult=true` |
| `AwesomeDudeScraper` | awesomedude.org | No | Chapter discovery via `.htm/.html` links in same directory; content extracted from largest non-layout block |
| `CodeysWorldScraper` | codeysworld.org | No | See below |
| `GayAuthorsScraper` | gayauthors.org | Optional | Genres + subgenres from `itemprop="genre"` links; tags from `ipsTags` list |
| `IomfatsScraper` | iomfats.org | No | See below; requires chapter URL as entry point |
| `NiftyNewScraper` | new.nifty.org | No | See below; registered before NiftyScraper |
| `NiftyScraper` | nifty.org (classic) | No | See below; excludes new.nifty.org; category/subcategory stored as tags |
| `TedLouisScraper` | tedlouis.com | No | Story index URL required as entry point; all pages use `?t=TOKEN` routing; chapter links in `<ul class="story-index-list">` |
#### NiftyNewScraper
`new.nifty.org` is a Next.js RSC application. Pages render proper HTML with semantic markup — no plain-text email format.
- URL normalisation: `_to_index_url()` strips a trailing `/N` (chapter index) so any URL (index or chapter) can be passed as entry point. Story URL pattern: `/stories/{slug}-{id}`.
- `fetch_book_info()`:
- Title from `<h1>`; fallback: `<title>` with ` - … - Nifty Archive …` suffix stripped.
- Author from `<strong itemprop="name">` inside `<a href="/authors/{id}">`.
- Publication date from `<time itemprop="datePublished" datetime="…">`, updated date from `<time itemprop="dateModified" datetime="…">`; both truncated to `YYYY-MM-DD`.
- Tags from all `<ul aria-label="Tags">` containers on the page — covers both the story category links (`/collections/…`) and the AI-generated content tags (`/search?query=tags%3A…`); deduplicated; `genres` and `subgenres` are always empty.
- Description from `<meta name="description">`.
- Chapter list: `<a>` links matching `/stories/{slug}/N` collected from page HTML; fallback: regex scan of RSC stream for `"index": N` values. URLs generated as `{index_url}/1``{index_url}/max`.
- `fetch_chapter()`:
- Content extraction order:
1. Chapter HTML (`{url}`): read `<article>` and collect `<p>` text
2. Fallback on same HTML: extract escaped Next payload paragraphs (`\u003cp...\u003c/p`)
3. Last fallback (`{url}?_rsc=1`): parse RSC line format (`{hex_id}:{json}`) for `["$","p",…]` nodes, then escaped paragraph fallback
- Chapter title uses the precomputed chapter dict title (`Chapter N`).
- Lead/tail boilerplate detection for common Nifty intro/donate text. Removed boilerplate is preserved as invisible HTML comments in chapter content:
- `<!-- NIFTY_HIDDEN_LEAD: ... -->`
- `<!-- NIFTY_HIDDEN_TAIL: ... -->`
- No email-header stripping and no plain-text line-joining (those are specific to Nifty classic).
#### NiftyScraper
Nifty classic pages are plain-text email submissions wrapped in a `<pre>` element.
- URL normalisation: `_to_index_url()` strips the chapter segment so any URL (index or chapter) can be passed as the entry point. Path structure: `/nifty/{category}/{subcategory}/{story}/` (index, 4 segments) vs `/nifty/{category}/{subcategory}/{story}/{chapter}` (chapter, 5 segments).
- `fetch_book_info()` performs up to 3 extra HTTP requests: chapter 1 (author + publication date), last chapter (`updated_date`), chapter 2 (boilerplate detection). Author and dates are extracted from the email headers (`From:`, `Date:`) embedded at the top of each chapter file. Date is parsed via `email.utils.parsedate``YYYY-MM-DD`.
- Boilerplate detection: leading paragraphs of chapters 1 and 2 (after email-header strip) are compared using normalised text (lowercase, whitespace collapsed). Consecutive matching paragraphs are recorded as `preamble_count` and stored in each chapter dict; `fetch_chapter()` skips them.
- `fetch_chapter()` pipeline:
1. Extract `<pre>` text (fallback: full body text)
2. Parse `Subject:` header → store as `<!-- Subject: … -->` comment in chapter content (invisible in reader, extractable later)
3. Strip email header block (up to first blank line after `Date:`/`From:`/`Subject:` lines)
4. Skip first `preamble_count` paragraphs
5. Split on blank lines → paragraphs; join hard-wrapped lines within each paragraph with a space
6. Detect and remove lead/tail boilerplate (common notice/disclaimer/author promo/donate blocks)
7. Persist removed boilerplate as invisible comments:
- `<!-- NIFTY_HIDDEN_LEAD: ... -->`
- `<!-- NIFTY_HIDDEN_TAIL: ... -->`
8. Scene-break patterns (`***`, `---`, `~~~`, `• • •`, etc.) → `<hr/>`
9. Build `content_el` as a BeautifulSoup `<div>` of comments + `<p>` + `<hr/>` nodes
- Genres/subgenres from URL path: `category` (e.g. `gay``Gay`) and `subcategory` (e.g. `young-friends``Young Friends`).
#### CodeysWorldScraper
- Entry point: any `codeysworld.org` URL.
- Title from `<h1>`; author from `<h2>` matching `"by …"` pattern; fallback: URL path segment `/{author}/{category}/filename`.
- Category from URL path (second-to-last segment, e.g. `remembrances` → tag `"Remembrances"`).
- Chapter discovery: `.htm/.html` links in the same directory as the entry URL; audio/image links skipped. No chapter links → single-file story (entry URL is the only chapter).
- `fetch_chapter()`: removes all `<h1>`/`<h2>` headings, back-navigation links, audio links (`.mp3`), mailto links; falls back to `<body>` when no content wrapper is found.
#### IomfatsScraper
All stories by an author are listed on a single author page (`/storyshelf/hosted/{author}/`). Individual story pages do not exist.
- Entry point must be a **chapter URL** (`/storyshelf/hosted/{author}/{story-folder}/{chapter}.html`). Passing the author page URL raises a `ValueError` with a user-visible message.
- On load: navigates to the author page and scans `<div id="content">` for the matching story.
- Two page structures detected:
- **Single story**: outer `<h3>` = book title; chapters are direct `<li><a>` children of the following `<ul>`.
- **Multi-part series**: outer `<h3>` = series name; nested `<li><h3>` = book title per part; chapters in the sub-`<ul>` matching `story_folder`.
- Series index extracted from folder name suffix: `*-part{N}` or `*-{N}`.
- Publication status from `<p><small>[…]</small></p>` after the book title heading.
- `fetch_chapter()`: content from `<div id="content">`; removes `<h2>`/`<h3>` headings, `.chapternav` divs, `div.important` footer blocks, anchor-name elements.
#### TedLouisScraper
All pages on `tedlouis.com` use opaque token-based routing: `https://tedlouis.com/?t=<TOKEN>`. There are no predictable URL patterns — tokens must be followed from the story index page.
- Entry point must be a **story index URL** (the page listing all chapters). Passing a chapter URL raises a `ValueError` with a user-visible message. Detection: story index has `<h2 class="story-page-title">`, chapter page has `<h1 class="story-title">`.
- `fetch_book_info()`:
- Title from direct `NavigableString` children of `<h2 class="story-page-title">` — the element also contains a "Back" button (`<a class="btn">`) and the author byline (`<span class="story-author-by-line">`), which are skipped.
- Author from `<span class="story-author-by-line"> <a>`.
- Publication status from `<span class="story-status-text">` with "Status: " prefix stripped.
- Updated date from `<span class="story-last-updated">` ("Last Updated: Month D, YYYY") → `YYYY-MM-DD`.
- Chapter list from all `<ul class="story-index-list">` elements (three columns on the page); relative `?t=TOKEN` hrefs resolved to absolute URLs. Order preserved; duplicates deduplicated.
- No genres, subgenres, tags or description available on the page.
- `fetch_chapter()`: content from `<div id="chapter">`; strips `<h1 class="story-title">`, `<h2 class="chapter-title">`, `div.chapter-copyright-line`, and `div.chapter-copyright-notice-text` blocks. Chapter title refined from `<h2 class="chapter-title"> <span>`.
#### `xhtml.element_to_xhtml()` — Comment handling
`bs4.Comment` objects (a `NavigableString` subclass) are now emitted as XML comments: `<!-- … -->`. The `--` sequence (illegal inside XML comments) is sanitised to `- -`. This allows scrapers to embed invisible metadata (e.g. the Nifty `Subject:` header) in chapter content without it appearing in the rendered reader.
### `routers/search.py` ### `routers/search.py`
- `GET /search` — full-text search page (`search.html`); Enter-to-search, `?q=` param auto-runs on load - `GET /search` — full-text search page (`search.html`); Enter-to-search, `?q=` param auto-runs on load
- `GET /api/search?q=…&mode=phrase|words&filter=all|unread_novels|unread_shorts` — FTS over `book_chapters.content_tsv`; `mode=phrase` (default) uses `phraseto_tsquery` (words in order); `mode=words` uses `plainto_tsquery` (all words present, any order); `ts_rank` and `ts_headline` always use `plainto_tsquery`; also matches chapters whose `title` contains the query (case-insensitive LIKE fallback); no result limit; excludes archived books; `filter=unread_novels` restricts to books with no reading sessions/progress and no `Shorts` tag; `filter=unread_shorts` restricts to books with no reading sessions/progress and a `Shorts` tag; results include `filename`, `title`, `author`, `chapter_index`, `chapter_title`, `snippet`, `rank` - `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` ### `routers/settings.py`
- `GET /settings` — settings page - `GET /settings` — settings page
- `GET /api/app-settings` — returns `{"develop_mode": bool, "break_image_url": str|null}`
- `PATCH /api/app-settings` — accepts `{"develop_mode": bool}`, persists to `app_settings` table
- `POST /api/app-settings/break-image` — multipart file upload (PNG/JPG/WebP); stores image in imagestore + overwrites `static/break.png`; saves `break_image_sha256` + `break_image_ext` to `app_settings`; returns `{"ok": true, "url": "/library/db-images/…"}`
- `GET /api/break-patterns` — list chapter-break patterns - `GET /api/break-patterns` — list chapter-break patterns
- `POST /api/break-patterns` — add break pattern (type: `regex` or `css_class`) - `POST /api/break-patterns` — add break pattern (type: `regex` or `css_class`)
- `PATCH /api/break-patterns/{id}` — update pattern (enable/disable or change value) - `PATCH /api/break-patterns/{id}` — update pattern (enable/disable or change value)
- `DELETE /api/break-patterns/{id}` — delete pattern - `DELETE /api/break-patterns/{id}` — delete pattern
- `DELETE /api/reading-history` — wipe all reading sessions - `DELETE /api/reading-history` — wipe all reading sessions
`app_settings` table (single row, `id = 1`): `develop_mode BOOLEAN`, `break_image_sha256 VARCHAR(64)`, `break_image_ext VARCHAR(10)`.
### `routers/builder.py` ### `routers/builder.py`
- `GET /builder` — Book Builder index (draft list + new draft form) - `GET /builder` — Book Builder index (draft list + new draft form)
- `POST /builder` — create new draft; redirects to `/builder/{id}` - `POST /builder` — create new draft; redirects to `/builder/{id}`
@ -483,21 +376,16 @@ Loaded by `index.html` (Convert page) and `grabber.html` (Grabber page). Require
- Amber: filled `#c8a03a`, unfilled `rgba(200, 160, 58, 0.25)`. - Amber: filled `#c8a03a`, unfilled `rgba(200, 160, 58, 0.25)`.
- Reader settings (hamburger menu): - Reader settings (hamburger menu):
- Content width slider (30100 vw), persisted as `reader-content-width-pct`. - Content width slider (30100 vw), persisted as `reader-content-width-pct`.
- Font size slider (80150%, default 105%), persisted as `reader-font-size`; applied via `--reader-font-size` CSS custom property on `#chapter-content`.
- Text colour: 5 warm-tone presets `#e8e2d9``#938d86`, persisted as `reader-text-colour`. - 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`. - Hamburger and back-link separated with `margin-left: 1rem` on `.header-back`.
- Reader supports EPUB, PDF, and CBR/CBZ: - Reader supports EPUB and PDF:
- EPUB: chapter-text rendering; progress = `{chapterIndex}:{scrollFrac}`; progress % = `(chapterIndex + scrollFrac) / total * 100`. - 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. - 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. - `reader.html` branches on `FORMAT` variable injected by the server.
- Series navigation: on load, `loadSeriesNav()` fetches `/api/series-nav/{filename}` and activates prev/next volume buttons in the header (hidden when no series); `markRead()` redirects to `/library/read/{next.filename}` when a next volume exists, otherwise to the book detail page.
- `Edit EPUB` button in Book Detail is only shown for `.epub` files. - `Edit EPUB` button in Book Detail is only shown for `.epub` files.
- Backup page supports: manual run, dry-run, Dropbox root, retention count, schedule (on/off + hours), status + history. - Backup page supports: manual run, dry-run, Dropbox root, retention count, schedule (on/off + hours), status + history.
- Bookmarks: saved per book via `POST /library/bookmarks/{filename}`; shown in Library sidebar section; navigated via `?bm_ch=N&bm_scroll=F` URL params on reader page. - Bookmarks: saved per book via `POST /library/bookmarks/{filename}`; shown in Library sidebar section; navigated via `?bm_ch=N&bm_scroll=F` URL params on reader page.
- 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`). - 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`).
- Authors view (`#authors`): lists all authors across `allBooks` (active + archived); authors whose books are all archived still appear. Sidebar counter (`count-authors`) counts only active-book authors. Author detail view (`#authors/{name}`) also uses `allBooks`; archived books show the `.badge-archived` overlay on their cover.
- Publishers view (`#publishers`): same rule — `allBooks` (active + archived); publishers with only archived books still appear. Sidebar counter uses active books only. Publisher detail also uses `allBooks`.
- Series detail view (`#series/{name}`): shows all books in a series as a cover grid. Header contains an "Archive series" / "Unarchive series" button — calls `POST /library/archive-series` to set `archived` for every book in the series at once; the button label reflects whether any book is still active.
- 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. - 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. - 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. - 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.
@ -505,17 +393,6 @@ Loaded by `index.html` (Convert page) and `grabber.html` (Grabber page). Require
--- ---
## Develop Mode
When enabled, every page shows a diagonal **DEVELOP** ribbon in the top-left corner and the browser tab title becomes **Novela Develop — …** instead of **Novela — …**.
- Persisted in `app_settings` table (single row, `id = 1`); created by `migrate_create_app_settings()`.
- `shared_templates._develop_mode()` reads this value from DB on every template render and is registered as a Jinja2 global (`develop_mode`), so all templates can use `{% if develop_mode() %}` without explicit context injection.
- Banner CSS lives in `static/sidebar.css` (`.develop-banner` / `.develop-banner-text`); rendered at the top of `templates/_sidebar.html`.
- Toggled via the **Develop mode** card on the Settings page (`/settings`); saving reloads the page so the banner and title take effect immediately.
---
## Known Conventions ## Known Conventions
- Book deletion flow: `unlink` file → `prune_empty_dirs(parent)``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. - 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.
@ -529,7 +406,6 @@ When enabled, every page shows a diagonal **DEVELOP** ribbon in the top-left cor
- CBR/PDF: DB only - 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. - `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()`. - Tag types in `book_tags`: `genre`, `subgenre`, `tag`, `subject`. No direct `genres`/`subgenres` fields on book objects; always use helpers `bookGenres()`, `bookSubgenres()`, `bookPlainTags()`.
- `series_volume` (e.g. `"1982"`) is used for annual comic series where issue numbers restart each year. It is separate from `series_index` (issue number within the year) and `series_suffix` (letter variant like `"a"`). Stored in DB and EPUB OPF (`novela:series_volume`); not reflected in the file path. Sort order: `series → series_volume → series_index → series_suffix`. In `getSeriesSlots`, gap-detection runs per volume independently when any book has `series_volume` set; slot labels show as `(year) #index`.
--- ---
@ -605,7 +481,6 @@ Images embedded in chapter HTML are stored content-addressed at `library/images/
## Known Bugs Fixed ## Known Bugs Fixed
- `renderGenreView` and `renderSearchResults` in `library.js` referenced `b.genres` (non-existent). Fixed: use `bookGenres()`, `bookSubgenres()`, `bookPlainTags()`. - `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()`. - `PillInput` in `book.js` did not handle comma as delimiter and did not flush on save. Fixed: comma keydown + `flush()` in `saveEdit()`.
- `PillInput._add` in `book.js` added a pasted comma-separated list as one tag instead of splitting it. Fixed: `_add` now splits the value on commas and pushes each trimmed, non-empty, non-duplicate part individually.
- `PATCH /library/book` failed for PDFs: `_sync_epub_metadata` tried to open PDF as ZIP. Fixed: only called for `.epub`. - `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`. - `_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. - `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.

View File

@ -1,220 +1,5 @@
# Develop Changelog # Develop Changelog
## 2026-04-15 (1)
- Reader: font size control in reading settings
- New "Font size" slider (80150%, default 105%) in the settings drawer, between "Content width" and "Text colour"
- Applies via CSS custom property `--reader-font-size` on `#chapter-content`
- Persisted per-device in `localStorage` as `reader-font-size` — iPad and desktop each remember their own preference
## 2026-04-13 (1)
- Edit metadata: comma-separated tag input fix
- `PillInput._add` in `book.js` now splits the incoming value on commas before adding — each trimmed, non-empty, non-duplicate part is pushed individually
- Applies to genres, subgenres and plain tags; pasting e.g. `Fiction, Thriller, Adventure` adds 3 separate pills instead of 1
---
*Released as v0.1.11 on 2026-04-13*
## 2026-04-12 (2)
- Series navigation in the reader
- New `GET /api/series-nav/{filename}` endpoint: returns `{prev, next}` with `{filename, title, index, suffix}` for adjacent books in the same series, ordered by `series_index ASC, series_suffix ASC`; returns `{prev: null, next: null}` for books without a series
- Reader header now shows prev/next series buttons (skip-to-start/skip-to-end icons); hidden for books without a series, visible once `loadSeriesNav()` resolves
- Hovering a series button shows a tooltip: `#<index><suffix> <title>` (e.g. `#4 Batman: Year One`)
- `markRead()` in the reader redirects to `/library/read/{next.filename}` when a next volume exists, so reading continues without leaving the reader; falls back to the book detail page when the series is complete or the book has no series
## 2026-04-12 (1)
- Comics: series_volume support for annual series (issue numbers restart each year)
- New `series_volume VARCHAR(20)` column on `library` (migration `migrate_series_volume`); default `''`
- Stored in EPUB OPF as `<meta name="novela:series_volume" content="…"/>`; read back by `scan_epub`
- `upsert_book` inserts/preserves `series_volume` with the same COALESCE strategy as `series_suffix`
- `list_library_json` ORDER BY now: `publisher → author → series → series_volume → series_index → title`
- `PATCH /library/book/{filename}` reads `series_volume` from request body; persists to DB and OPF for both file-based and DB-stored books
- Book detail page: displays `(year)` after series name when `series_volume` is set (e.g. `Donald Duck (1982) [15]`); edit panel has a new "Year/Volume" input field
- `book.js`: "Auto" button next to the Title field generates `Series (Year/Vol) #Number` from the current series fields
- Bulk importer: `series_volume` support
- New `%series_volume%` placeholder token (orange) for filename pattern parsing
- New "Year/Vol." shared metadata field (applies to all files; overridden by per-row value or pattern)
- Preview table has a new "Yr/Vol" column
- New "Auto-generate titles from series info" checkbox: when enabled, rows without a parsed title get title `Series (Year/Vol) #Number`
- Skip checkbox now always visible for every row (previously only shown when duplicates were detected); any file can be manually excluded before import; skipped rows are dimmed; stats bar shows "X skipped"
- `POST /api/bulk-check-duplicates` extended: now also checks `(series, series_index, author)` as fallback — detects duplicates even when the title format has changed; items must include `series` field
- Library front-end: sorting within a series now respects `series_volume` before `series_index`
- `groupBySeries`, `renderAuthorDetail`, `renderPublisherDetail` all sort by `series_volume → series_index → series_suffix`
- `getSeriesSlots` refactored: when any book in the series has `series_volume` set, gap-detection runs per volume (year) independently — prevents `#5 (1982)` and `#5 (1983)` from colliding in the same slot
- Slot index label shows `(year) #index` for annual series (e.g. `(1982) #5`); unchanged for regular series
---
*Released as v0.1.10 on 2026-04-12*
## 2026-04-08 (13)
- Library: archive series in one action
- "Archive series" / "Unarchive series" button in the series detail view (`#series/{name}`)
- New endpoint `POST /library/archive-series` — sets `archived` for all books in the series via a single SQL UPDATE; body: `{"series": "…", "archive": true|false}`; returns `{ok, archived, count}`
- Button label reflects current state: "Archive series" when any book is active, "Unarchive series" when all are archived
- After the call, `allBooks` is updated in place and sidebar counters are recalculated without a full page reload
## 2026-04-08 (12)
- TedLouisScraper: title extraction fix
- `<h2 class="story-page-title">` also contains a "Back" button (`<a class="btn">`) and the author byline (`<span class="story-author-by-line">`)
- Fix: only direct `NavigableString` children of the h2 are used as the title, so link and span text is skipped
## 2026-04-08 (11)
- New scraper: `TedLouisScraper` (`scrapers/tedlouis.py`) for `tedlouis.com`
- Matches all `tedlouis.com` URLs; no login required
- Entry point is the story index page (e.g. `?t=CWYSqpOryu2rQmT1`); raises an error when a chapter URL is used as entry point
- Title from direct text nodes of `<h2 class="story-page-title">`
- Author from `<span class="story-author-by-line"> <a>`
- Status from `<span class="story-status-text">` (strips "Status: " prefix)
- Updated date from `<span class="story-last-updated">``YYYY-MM-DD`
- Chapters from all `<ul class="story-index-list">` elements (three columns); relative `?t=TOKEN` links resolved to absolute URLs
- `fetch_chapter()`: content from `<div id="chapter">`; removes story title, chapter title and copyright blocks
## 2026-04-08 (10)
- Settings: break image upload added
- New "Break image" card on the settings page: upload a PNG/JPG/WebP as the scene break image
- Stored in the imagestore (sha256-addressed) and overwrites `static/break.png` so EPUB export uses the same image
- `app_settings` extended with `break_image_sha256` and `break_image_ext` columns (migration `migrate_app_settings_break_image`)
- New endpoints: `POST /api/app-settings/break-image`; `GET /api/app-settings` now also returns `break_image_url`
- Preview of the current break image visible on the settings page
## 2026-04-08 (9)
- Grabber: break image fix for DB-stored books
- Break images (`<center><img src="../Images/break.png">`) contain a relative EPUB path that does not exist in the DB context
- Fix: `storage_mode` determined earlier in `_run_scrape`; for DB mode `/static/break.png` is passed as `break_img_path` to `element_to_xhtml`, for EPUB mode `../Images/break.png` is kept
- Additional fix: when a break image is detected in the image loop, the parent (`<center>`) is replaced with `<hr>` instead of decomposing the `<img>`; this allows `element_to_xhtml` to correctly detect the break after the image loop
## 2026-04-08 (8)
- New scraper: `IomfatsScraper` (`scrapers/iomfats.py`) for `iomfats.org`
- Matches all `iomfats.org` URLs; no login required
- Entry point is a chapter URL (e.g. `.../grasshopper/justhitsend-part1/justhitsend01.html`); automatically navigates to the author page to fetch all metadata and chapters
- Author page as entry point: raises a user-visible error message
- Detects two structures on the author page:
- Single story: outer `<h3>` = book title; chapters directly in `<ul>`
- Multi-part series: outer `<h3>` = series name; nested `<li><h3>` = book title per part; chapters in sub-`<ul>`
- Series index from folder name: `*-part{N}` or `*-{N}``series_index_hint`
- Publication status from `<p><small>[...]</small></p>` after book title
- `fetch_chapter()`: content via `<div id="content">`; removes `<h2>`/`<h3>` headings, chapternav divs, footer elements
## 2026-04-08 (7)
- New scraper: `CodeysWorldScraper` (`scrapers/codeysworld.py`) for `codeysworld.org`
- Matches all `codeysworld.org` URLs; no login required
- Title from `<h1>`; author from `<h2>` ("by …"); fallback to URL slug (`/{author}/{category}/filename`)
- Category from URL path → tag (e.g. "Remembrances")
- Single-file stories (no chapter links): the page itself is the only chapter
- Multi-chapter: links to `.htm`/`.html` files in the same directory (audio/image links skipped)
- `fetch_chapter()`: removes all `<h1>`/`<h2>` headings, navigation links ("Back to …"), audio links (`.mp3`), mailto links
- Nifty scraper: category/subcategory moved from `genres`/`subgenres` to `tags`
## 2026-04-08 (6)
- NiftyNewScraper: chapter extraction made more robust
- `fetch_chapter()` now first tries the standard chapter HTML (`{url}`) and reads `<article><p>` directly
- Fallback added for Next payloads with escaped paragraphs (`\u003cp...\u003c/p`) via `_extract_escaped_html_paragraphs()`
- Last fallback remains `?_rsc=1`: first `_parse_rsc_paragraphs()`, then escaped-paragraph fallback again
- Nifty (classic + new): standard boilerplate no longer visible in reader, but preserved in chapter
- Lead/tail detection added for common blocks (e.g. `NOTICE This is a work of fiction…`, `If you enjoy this story…`, donate text)
- Detected intro/closing boilerplate removed from visible paragraphs
- Removed text stored as invisible HTML comment:
- `<!-- NIFTY_HIDDEN_LEAD: ... -->`
- `<!-- NIFTY_HIDDEN_TAIL: ... -->`
- Detection also works when text contains inline HTML (e.g. `<a>` or `<svg>` in donate links)
## 2026-04-08 (5)
- NiftyNewScraper: chapter content fix
- `new.nifty.org` uses Next.js RSC — chapter content is not present in the static HTML but in the RSC payload (React component tree)
- `fetch_chapter()` now fetches `{url}?_rsc=1` instead of the regular HTML page
- RSC parser added: `_parse_rsc_paragraphs()`, `_rsc_find_paragraphs()`, `_rsc_text()` — parse the RSC stream line by line (format: `{hex_id}:{json}`), recursively search for `["$","p",null,{...}]` nodes and extract text from `children`
## 2026-04-08 (4)
- NiftyNewScraper added (`scrapers/nifty_new.py`) for `new.nifty.org`
- Matches all `new.nifty.org` URLs; no login required
- `_to_index_url()`: strips trailing `/N` (chapter number) so both index and chapter URLs can be used as entry point
- `fetch_book_info()`: title from `<h1>`, author from `<strong itemprop="name">` in author link, dates from `<time itemprop="datePublished/dateModified">`, tags from all `<ul aria-label="Tags">` containers (category links and generated tags, deduplicated), description from `<meta name="description">`, chapter list via `<a>` links matching `/stories/{slug}/N` (RSC stream regex as fallback)
- `fetch_chapter()`: title from JSON-LD `@type: "Chapter"`, content from `<article>`; no email header stripping, no line joining, no boilerplate detection
- `NiftyScraper.matches()` updated: excludes `new.nifty.org` (`"nifty.org" in url and "new.nifty.org" not in url`)
- `NiftyNewScraper` registered before `NiftyScraper` in `scrapers/__init__.py`
## 2026-04-08 (3)
- Settings: develop mode added
- New `app_settings` table (single row, `id = 1`) with `develop_mode` boolean; created via `migrate_create_app_settings()`
- `shared_templates.py`: shared `Jinja2Templates` instance for all routers; `develop_mode()` registered as Jinja2 global so all templates can access it without explicit context injection
- All 11 routers now import `templates` from `shared_templates` instead of each creating their own instance
- New endpoints in `routers/settings.py`: `GET /api/app-settings` and `PATCH /api/app-settings`
- Diagonal **DEVELOP** banner in the top-left of every page (CSS in `static/sidebar.css`, HTML in `templates/_sidebar.html`); only visible when develop mode is enabled
- All 17 HTML templates: `<title>` shows **Novela Develop — …** when develop mode is active
- Settings page: new card with checkbox toggle; page reloads after saving so banner and title apply immediately
## 2026-04-08 (2)
- Nifty scraper: fix `_strip_email_headers` now tolerates blank lines between header fields
- Some Nifty pages place `Subject:` after a blank line (`Date:\nFrom:\n\nSubject:\n`) — the previous implementation stopped at the first blank line causing `Subject:` to appear as a paragraph in chapter text
- Fix: on a blank line, look ahead to check whether a header field still follows; if so, skip the blank line(s) and continue stripping
## 2026-04-08 (1)
- Nifty scraper added (`scrapers/nifty.py`)
- Matches all `nifty.org` URLs; no login required
- `fetch_book_info`: accepts index or chapter URL; normalises to index; title from URL slug, author and publication date from email headers of chapter 1, `updated_date` from email headers of the last chapter; genres/subgenres from URL path (`/nifty/{category}/{subcategory}/…`)
- Boilerplate detection: compares first paragraphs of chapters 1 and 2 after header stripping; matching paragraphs are skipped in all chapters (`preamble_count` per chapter dict)
- `fetch_chapter`: retrieves `<pre>` content (fallback: body); subject header stored as invisible HTML comment `<!-- Subject: … -->` at the top of chapter content; email headers stripped; hard line breaks within paragraphs joined into a single line; scene breaks (`***`, `---`, etc.) → `<hr/>`
- Date parsing via `email.utils.parsedate``YYYY-MM-DD`
- `xhtml.py`: `element_to_xhtml` now handles `bs4.Comment` objects as XML comments (`<!-- … -->`); `--` in the body is sanitised to `- -` (illegal in XML comments)
## 2026-04-06 (3)
- Book detail: rating moved from clickable stars to dropdown in the Edit metadata panel
- Stars on the detail page are now purely visual (no longer clickable)
- New `<select id="ed-rating">` field in the edit panel, directly below Status
- `openEdit()` populates the select with the current rating; `saveEdit()` calls `POST /library/rating/…` if the value has changed
- `rateBook()` function removed from `book.js`
- Overview pages unchanged
## 2026-04-06 (2)
- Library: cover upload now also supported for DB-stored books
- `POST /library/cover/{filename}` previously returned an error for DB books (`"File not found"`) because no physical file exists
- Fix: DB books are now detected via `is_db_filename`; cover is stored directly in `library_cover_cache` and `has_cover = TRUE` set in the `library` table — `add_cover_to_epub` is not called
## 2026-04-06 (1)
- Search: filter for unread novels / unread shorts
- New `filter` parameter on `GET /api/search?q=…&filter=all|unread_novels|unread_shorts` (default: `all`)
- `unread_novels`: restricts to books with no reading sessions/progress and no `Shorts` tag
- `unread_shorts`: restricts to books with no reading sessions/progress and with a `Shorts` tag
- UI: second toggle row (All / Unread novels / Unread shorts) below the Phrase/All words toggle
- Filter persisted in URL (`?filter=…`) and restored on page load
## 2026-04-05 (4)
- Backup: large files (> 148 MB) now uploaded via Dropbox upload session in 100 MB chunks
- `_dropbox_upload_bytes`: files ≤ 148 MB go via `files_upload` (unchanged); larger files via `upload_session_start``upload_session_append_v2``upload_session_finish`
- Fixes `ApiError: UploadError('payload_too_large')`
## 2026-04-05 (3)
- Filenames: spaces now replaced with underscores when saving new files
- `clean_segment` (common.py) and `_clean_segment` (reader.py): `\s+``_` instead of space
- Series separator changed from ` - ` to `_-_` (applies to EPUB, CBR, CBZ and DB filenames)
- Existing files are not renamed
## 2026-04-05 (2)
- Search: removed result limit; added Phrase / All words mode toggle
- `LIMIT 30` removed — all matching chapters are returned
- New `mode` parameter on `GET /api/search?q=…&mode=phrase|words`: `phrase` (default) requires words in order (`phraseto_tsquery`); `words` requires all words present in any order (`plainto_tsquery`)
- Toggle in the UI (Phrase / All words) above the results; mode is included in the URL
## 2026-04-05 (1)
- Export EPUB: double chapter titles fixed — same heading-stripping logic as the reader now applied before passing content to `make_chapter_xhtml`
- Library: authors and publishers with only archived books now remain visible in the Authors and Publishers list views
- `renderAuthorsView` and `renderPublishersView` switched from `activeBooks()` to `allBooks` — consistent with `renderAuthorDetail` and `renderPublisherDetail`
## 2026-04-04 (2)
- Reader: fixed double chapter titles for books where the heading is wrapped in a `<section>` element (pandoc-style)
- Previous regex only stripped `<h1>``<h4>` at the very start of content; pandoc-converted EPUBs wrap headings as `<section …><h1>…</h1>` — heading was not at position 0 so the regex didn't fire
- Added a second `re.sub` pass that removes the first `<h1>``<h4>` found directly after an opening `<section>` or `<div>` tag, preserving the wrapper element
- Same stripping applied in the DB→EPUB export (`export_epub`) before passing content to `make_chapter_xhtml`
- Search: switched from `plainto_tsquery` to `phraseto_tsquery` in the FTS WHERE clause
- `plainto_tsquery` ANDs all words but treats them as independent terms (any order, any distance) — multi-word queries like "4 years later" matched chapters where the words appeared far apart
- `phraseto_tsquery` requires all words to appear in sequence; `ts_rank` and `ts_headline` still use `plainto_tsquery` for correct scoring and highlighting
## 2026-04-04 (1)
- Reader: fixed double chapter titles in DB-stored books
- Chapter endpoint (`GET /library/chapter/{index}/{filename}`) now strips all leading `<h1>``<h4>` tags from stored content before prepending its own `<h2 class="chapter-title">` — books scraped before front-matter stripping was added to the scraper showed the title (and chapter heading) twice
- Library: archived books now visible in author and publisher detail views, with an indicator badge on the cover
- `renderAuthorDetail` and `renderPublisherDetail` reverted to use `allBooks` (including archived) so archived books remain accessible from the author/publisher pages
- New `.badge-archived` overlay (bottom-left of cover): dark circle with archive icon, consistent with existing `badge-status` and `read-pill` overlays; added to all three card-rendering code paths
## 2026-04-03 (3) ## 2026-04-03 (3)
- DB chapter editor: Monaco-based editor now supports DB-stored books - 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 /library/editor/{filename}` handles `db/…` filenames; `is_db` flag passed to template

View File

@ -1,109 +1,5 @@
# Changelog # Changelog
## v0.1.12 — 2026-04-15
### New features
- Reader: **font size slider** in the reading settings drawer — adjust text size from 80% to 150%; setting is saved per device so iPad and desktop each remember their own preference
---
## v0.1.11 — 2026-04-13
### Bug fixes
- Edit metadata: pasting or typing a comma-separated list in the genre, subgenre or tag input now adds each value as a separate tag instead of one combined tag
---
## v0.1.10 — 2026-04-12
### New features
- Reader: **prev/next volume buttons** in the header for books that are part of a series — buttons appear automatically when the book has adjacent volumes; tooltip shows the volume number and title; marking a book as read redirects directly to the next volume in the reader instead of the book detail page
- Comics: **series_volume field** for annual series where issue numbers restart each year (e.g. Donald Duck (1982) [15]) — stored in the database and EPUB OPF; displayed as `(year)` after the series name on the book detail page; sorting respects `series_volume` before `series_index`; supported in Bulk Import via `%series_volume%` placeholder and a "Year/Vol." shared field
- Library: **archive a series in one click** — "Archive series" / "Unarchive series" button in the series detail view; updates all books in the series via a single SQL UPDATE and recalculates sidebar counters without a page reload
### Bug fixes
- TedLouis scraper: title extraction no longer includes the "Back" button text or the author byline — only direct text nodes of the title heading are used
---
## v0.1.9 — 2026-04-08
### New features
- New scraper: **Nifty.org (classic)** — scrapes plain-text email-format stories; email headers stripped, boilerplate paragraphs auto-detected and hidden, scene-break patterns converted to break images
- New scraper: **new.nifty.org** — scrapes the Next.js version of Nifty; reads chapter content from the RSC payload when the static HTML does not include it; boilerplate detection shared with classic Nifty
- New scraper: **codeysworld.org** — single-file and multi-chapter stories; title and author extracted from heading elements; category from URL path stored as tag; navigation links and audio links stripped from chapter content
- New scraper: **iomfats.org** — all stories are listed on a single author page; provide any chapter URL and the scraper finds the correct story automatically; supports single stories and multi-part series (series name, book title, and series index derived from the page structure)
- New scraper: **tedlouis.com** — all pages use opaque token-based routing (`?t=TOKEN`); provide the story index URL and the scraper collects all chapter links from the three-column chapter list
- Settings: break image upload — upload a custom PNG/JPG/WebP to use as the scene break image in all converted books; stored in the imagestore and applied to both DB-stored and EPUB-format books
- Settings: develop mode toggle — shows a DEVELOP banner and updates the page title across all pages when enabled
### Bug fixes
- Break images were not displayed in DB-stored books — the image path `../Images/break.png` is a relative EPUB path that does not exist for DB content; DB mode now uses `/static/break.png`
- Break images were silently lost during import — the image was decomposed before `element_to_xhtml` ran, leaving an empty wrapper; the wrapper is now replaced with `<hr>` so the break is correctly rendered
---
## v0.1.8 — 2026-04-06
### Bug fixes
- Library: cover upload now works for DB-stored books — the upload endpoint previously returned "File not found" because DB books have no file on disk; the cover is now stored directly in the cover cache
### Improvements
- Book detail: rating moved from clickable stars to a dropdown in the Edit metadata panel — avoids touch-input issues on iPad where hover state caused all stars to appear filled
---
## v0.1.7 — 2026-04-06
### New feature
- Search: filter on unread novels or unread shorts — a second toggle row (All / Unread novels / Unread shorts) restricts results to books with no reading history; filter is preserved in the URL
### Bug fixes
- Backup: files larger than 148 MB now upload correctly — chunked upload session (100 MB per chunk) replaces the single-call upload that hit Dropbox's payload size limit
### Improvements
- File paths: spaces in new filenames are now replaced with underscores (publisher, author, title, series segments); series separator changed from ` - ` to `_-_`
---
## v0.1.6 — 2026-04-05
### Bug fixes
- Export EPUB: double chapter titles fixed — same heading-stripping logic as the reader now applied before passing content to `make_chapter_xhtml`
- Library: authors and publishers with only archived books now remain visible in the Authors and Publishers list views
---
## v0.1.5 — 2026-04-04
### Bug fixes
- Reader: double chapter titles for pandoc-converted books — headings wrapped in a `<section>` element were not stripped by the previous regex; now also removes the first heading found directly inside an opening `<section>` or `<div>`
- Search: multi-word queries no longer match chapters where the words appear far apart — switched to `phraseto_tsquery` so all words must appear in order
---
## v0.1.4 — 2026-04-04
### Bug fixes
- Reader: double chapter titles in DB-stored books — the chapter endpoint now strips all leading headings from stored content before prepending its own chapter title; affects books scraped before front-matter stripping was added
- Library: archived books were missing from author and publisher detail views — detail views now include all books (active and archived); archived books have a badge on their cover so they remain distinguishable
---
## v0.1.3 — 2026-04-03 ## v0.1.3 — 2026-04-03
### New feature ### New feature

View File

@ -1 +1 @@
v0.2.8 v0.1.8