diff --git a/README.md b/README.md index 30bb811..ccff0b7 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,65 @@ # Novela Novela is a self-hosted web application for managing and reading a personal digital library. -It supports EPUB, PDF, and CBR/CBZ, with metadata editing, reading progress tracking, and Dropbox backups. +It supports EPUB, PDF, and CBR/CBZ, with metadata editing, reading progress tracking, a web scraper/converter, and Dropbox backups. ## What Novela Provides -- Library import and indexing for EPUB/PDF/CBR/CBZ -- Home dashboard with continue reading and unread/read sections -- Reader support for EPUB, PDF, and comics (CBR/CBZ) -- Metadata editing (title, author, publisher, series, volume, tags, genres) -- `New` review workflow with list/grid view, column toggles, and bulk actions -- Reading analytics/statistics dashboard -- Dropbox backup with: - - versioned snapshots - - object deduplication - - retention policy - - scheduled background runs + +### Library +- Import and indexing for EPUB, PDF, CBR/CBZ +- Drag-and-drop import from library page or home page +- Cover extraction and caching (EPUB, PDF first page, CBR/CBZ first page); manual cover upload for EPUB +- Metadata editing: title, author, publisher, series, volume, tags, genres, sub-genres, star rating, publication status +- Publication statuses: Complete, Ongoing, Temporary Hold, Long-Term Hold +- Want-to-read flag and archived flag +- 1–5 star ratings (stored in EPUB OPF / CBZ ComicInfo.xml / DB) +- Download individual files + +### Views and Navigation +- Home dashboard: continue reading, unread shorts/novels, read shorts/novels +- Library grid/list views with search (title, author, genre) +- Series view: grouped by series with volume order +- Duplicates view: groups books with matching title+author +- Incomplete view: books not marked Complete +- New view: recently imported books awaiting review; bulk "Remove from New" action +- Following page: track external URLs per author + +### Bulk Import +- `/bulk-import` page: import multiple files at once with filename-based metadata parsing +- Free-text `%placeholder%` pattern editor (e.g. `%series% - %volume% - %title% - %year%`) +- Available placeholders: `%series%`, `%volume%`, `%title%`, `%year%`, `%month%`, `%day%`, `%author%`, `%publisher%`, `%ignore%` +- Colored chips: click to insert at cursor or drag onto the pattern input +- Shared metadata fields (author, publisher, status, genres, tags) override filename-parsed values +- Preview table with editable cells before importing +- Duplicate detection: checks title+author against existing library; duplicate rows highlighted, skipped by default +- Batched upload (5 files per request) with progress bar +- Batched bulk delete (20 files per request) with progress bar + +### Reader +- EPUB reader: chapter navigation, configurable content width and text colour, bookmarks +- PDF reader: page-by-page rendering +- CBR/CBZ reader: page-by-page image rendering +- Reading progress saved per book (resume where you left off) +- Bookmarks with notes; listed in library sidebar section + +### EPUB Tools +- EPUB editor (`/library/editor/{filename}`): edit chapter HTML in the browser +- Book Builder (`/builder`): create EPUB books from scratch with a WYSIWYG editor; publish directly to library +- Web Grabber (`/grabber`): scrape and convert web fiction to EPUB; site credentials management +- Converter (`/convert`): convert a URL to EPUB; duplicate detection before conversion + +### Analytics +- Statistics dashboard: reads by month / day / hour, genre and publisher breakdowns, top books, read history + +### Backups +- Dropbox backup: versioned snapshots with object-level deduplication and retention policy +- OAuth2 flow (preferred) or legacy access token +- Scheduled background backups (configurable interval) +- Live progress indicator during backup runs +- PostgreSQL dump included in each backup + +### Sidebar +- Disk usage warning: amber ≥ 85% or < 2 GB free; red ≥ 95% or < 500 MB free ## Tech Stack - FastAPI @@ -23,10 +68,10 @@ It supports EPUB, PDF, and CBR/CBZ, with metadata editing, reading progress trac - Docker / Docker Compose style deployment ## Repository Layout -- `containers/novela/` - application code (routers, templates, static assets, migrations) -- `stack/` - deployment stack files and environment configuration -- `docs/` - technical status and changelog documentation -- `build-and-push.sh` - helper script for container build/push +- `containers/novela/` — application code (routers, templates, static assets, migrations) +- `stack/` — deployment stack files and environment configuration +- `docs/` — technical status and changelog documentation +- `build-and-push.sh` — helper script for container build/push ## Quick Start (Development) 1. Configure environment values in `stack/novela.env`. diff --git a/containers/novela/Dockerfile b/containers/novela/Dockerfile index 61835d2..ddf227e 100644 --- a/containers/novela/Dockerfile +++ b/containers/novela/Dockerfile @@ -2,10 +2,11 @@ FROM python:3.12-slim WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN echo "deb http://deb.debian.org/debian bookworm non-free" >> /etc/apt/sources.list \ + && apt-get update && apt-get install -y --no-install-recommends \ build-essential \ libmagic1 \ - unrar-free \ + unrar \ postgresql-client \ && rm -rf /var/lib/apt/lists/* diff --git a/containers/novela/cbr.py b/containers/novela/cbr.py index 621e7a6..f425d12 100644 --- a/containers/novela/cbr.py +++ b/containers/novela/cbr.py @@ -2,21 +2,39 @@ from io import BytesIO from pathlib import Path import zipfile +import py7zr import rarfile from PIL import Image, ImageOps SUPPORTED_IMG = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"} +_MAGIC_RAR = b"Rar!\x1a\x07" +_MAGIC_ZIP = b"PK" +_MAGIC_7Z = b"7z\xbc\xaf\x27\x1c" -def _is_cbz(path: Path) -> bool: - return path.suffix.lower() == ".cbz" +def _detect_format(path: Path) -> str: + """Detect archive format by magic bytes, ignoring file extension.""" + with open(path, "rb") as f: + header = f.read(8) + if header[:6] == _MAGIC_RAR: + return "rar" + if header[:2] == _MAGIC_ZIP: + return "zip" + if header[:6] == _MAGIC_7Z: + return "7z" + # Fallback: trust the extension + return "zip" if path.suffix.lower() == ".cbz" else "rar" def cbr_page_list(path: Path) -> list[str]: - if _is_cbz(path): + fmt = _detect_format(path) + if fmt == "zip": with zipfile.ZipFile(path) as zf: names = [n for n in zf.namelist() if Path(n).suffix.lower() in SUPPORTED_IMG] + elif fmt == "7z": + with py7zr.SevenZipFile(path, mode="r") as zf: + names = [n for n in zf.getnames() if Path(n).suffix.lower() in SUPPORTED_IMG] else: with rarfile.RarFile(path) as rf: names = [n for n in rf.namelist() if Path(n).suffix.lower() in SUPPORTED_IMG] @@ -42,11 +60,17 @@ def cbr_get_page(path: Path, page_num: int) -> tuple[bytes, str]: "bmp": "image/bmp", }.get(ext, "image/jpeg") - if _is_cbz(path): + fmt = _detect_format(path) + if fmt == "zip": with zipfile.ZipFile(path) as zf: return zf.read(name), mime - with rarfile.RarFile(path) as rf: - return rf.read(name), mime + elif fmt == "7z": + with py7zr.SevenZipFile(path, mode="r") as zf: + data = zf.read(targets=[name]) + return data[name].read(), mime + else: + with rarfile.RarFile(path) as rf: + return rf.read(name), mime def cbr_cover_thumb(path: Path) -> bytes: diff --git a/containers/novela/main.py b/containers/novela/main.py index e57425e..36c7645 100644 --- a/containers/novela/main.py +++ b/containers/novela/main.py @@ -10,6 +10,7 @@ from routers.backup import start_backup_scheduler, stop_backup_scheduler from routers import ( backup_router, builder_router, + bulk_import_router, editor_router, following_router, grabber_router, @@ -41,6 +42,7 @@ app.include_router(grabber_router) app.include_router(settings_router) app.include_router(backup_router) app.include_router(builder_router) +app.include_router(bulk_import_router) app.include_router(following_router) diff --git a/containers/novela/migrations.py b/containers/novela/migrations.py index 3c517a4..5fd144f 100644 --- a/containers/novela/migrations.py +++ b/containers/novela/migrations.py @@ -292,6 +292,10 @@ def migrate_create_authors() -> None: ) +def migrate_rename_hiatus() -> None: + _exec("UPDATE library SET publication_status = 'Long-Term Hold' WHERE publication_status = 'Hiatus'") + + def run_migrations() -> None: migrate_create_library() migrate_create_book_tags() @@ -309,3 +313,4 @@ def run_migrations() -> None: migrate_series_suffix() migrate_create_builder_drafts() migrate_create_authors() + migrate_rename_hiatus() diff --git a/containers/novela/requirements.txt b/containers/novela/requirements.txt index 85697da..29b440b 100644 --- a/containers/novela/requirements.txt +++ b/containers/novela/requirements.txt @@ -9,6 +9,7 @@ jinja2==3.1.4 Pillow==11.0.0 pymupdf==1.24.0 rarfile==4.2 +py7zr==0.22.0 dropbox==12.0.2 apscheduler==3.10.4 cryptography==44.0.1 diff --git a/containers/novela/routers/__init__.py b/containers/novela/routers/__init__.py index 9715803..7a90ec2 100644 --- a/containers/novela/routers/__init__.py +++ b/containers/novela/routers/__init__.py @@ -1,5 +1,6 @@ from routers.backup import router as backup_router from routers.builder import router as builder_router +from routers.bulk_import import router as bulk_import_router from routers.editor import router as editor_router from routers.following import router as following_router from routers.grabber import router as grabber_router @@ -15,5 +16,6 @@ __all__ = [ "backup_router", "settings_router", "builder_router", + "bulk_import_router", "following_router", ] diff --git a/containers/novela/routers/bulk_import.py b/containers/novela/routers/bulk_import.py new file mode 100644 index 0000000..c8ce7fd --- /dev/null +++ b/containers/novela/routers/bulk_import.py @@ -0,0 +1,145 @@ +import json +import uuid +from pathlib import Path + +from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates + +from cbr import cbr_page_count +from db import get_db_conn +from routers.common import ( + LIBRARY_DIR, + ensure_cover_cache_for_book, + ensure_unique_rel_path, + make_rel_path, + media_type_from_suffix, + parse_volume_str, + upsert_book, +) + +templates = Jinja2Templates(directory="templates") +router = APIRouter() + + +@router.get("/bulk-import", response_class=HTMLResponse) +async def bulk_import_page(request: Request): + return templates.TemplateResponse(request, "bulk_import.html", {"active": "bulk_import"}) + + +@router.post("/library/bulk-import") +async def library_bulk_import( + files: list[UploadFile] = File(...), + rows: str = Form(...), + shared: str = Form("{}"), +): + try: + rows_data = json.loads(rows) + shared_data = json.loads(shared) + except Exception: + return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400) + + rows_by_name = {r["original_filename"]: r for r in rows_data} + shared_author = shared_data.get("author", "") + shared_publisher = shared_data.get("publisher", "") + shared_status = shared_data.get("status", "") + shared_genres = [t.strip() for t in shared_data.get("genres", "").split(",") if t.strip()] + shared_tags = [t.strip() for t in shared_data.get("tags", "").split(",") if t.strip()] + + imported: list[str] = [] + skipped: list[dict] = [] + + for upload in files: + try: + name = upload.filename or "upload.bin" + suffix = Path(name).suffix.lower() + if suffix not in {".epub", ".pdf", ".cbr", ".cbz"}: + skipped.append({"file": name, "reason": "Unsupported file type"}) + continue + + mt = media_type_from_suffix(Path(name)) + if not mt: + skipped.append({"file": name, "reason": "Could not detect media type"}) + continue + + data = await upload.read() + if not data: + skipped.append({"file": name, "reason": "Empty upload"}) + continue + + row = rows_by_name.get(name, {}) + + title = (row.get("title") or "").strip() or Path(name).stem + author = (row.get("author") or "").strip() or shared_author + publisher = (row.get("publisher") or "").strip() or shared_publisher + series = (row.get("series") or "").strip() or shared_data.get("series", "") + series_index, series_suffix = parse_volume_str(row.get("volume") or "") + status = (row.get("status") or "").strip() or shared_status + + year = str(row.get("year") or "").strip() + publish_date: str | None = f"{year}-01-01" if year.isdigit() and len(year) == 4 else None + + row_genres = [t.strip() for t in (row.get("genres") or "").split(",") if t.strip()] + row_tags = [t.strip() for t in (row.get("tags") or "").split(",") if t.strip()] + genres = row_genres if row_genres else shared_genres + plain_tags = row_tags if row_tags else shared_tags + + tmp = LIBRARY_DIR / f".import-{uuid.uuid4().hex}{suffix}" + tmp.parent.mkdir(parents=True, exist_ok=True) + tmp.write_bytes(data) + + rel = ensure_unique_rel_path( + make_rel_path( + media_type=mt, + publisher=publisher, + author=author, + title=title, + series=series, + series_index=series_index, + series_suffix=series_suffix, + ext=suffix, + ) + ) + dest = LIBRARY_DIR / rel + dest.parent.mkdir(parents=True, exist_ok=True) + tmp.replace(dest) + + rel_name = rel.as_posix() + + has_cover = False + try: + if mt == "cbr": + has_cover = cbr_page_count(dest) > 0 + except Exception: + pass + + meta = { + "media_type": mt, + "title": title, + "author": author, + "publisher": publisher, + "series": series, + "series_index": series_index, + "series_suffix": series_suffix, + "publication_status": status, + "publish_date": publish_date, + "has_cover": has_cover, + "needs_review": True, + "description": "", + "source_url": "", + } + tag_tuples = [(g, "genre") for g in genres] + [(t, "tag") for t in plain_tags] + + with get_db_conn() as conn: + with conn: + upsert_book(conn, rel_name, meta, tag_tuples) + ensure_cover_cache_for_book(conn, rel_name, dest, mt) + + imported.append(rel_name) + + except Exception as e: + skipped.append({"file": upload.filename or "upload", "reason": str(e)}) + finally: + await upload.close() + + return {"ok": True, "imported": imported, "skipped": skipped} diff --git a/containers/novela/routers/common.py b/containers/novela/routers/common.py index bf7e3cb..b782195 100644 --- a/containers/novela/routers/common.py +++ b/containers/novela/routers/common.py @@ -100,6 +100,11 @@ def make_rel_path(*, media_type: str, publisher: str, author: str, title: str, s pub = clean_segment(publisher, "Unknown Publisher", 80) auth = clean_segment(author, "Unknown", 80) ttl = clean_segment(title, "Untitled", 140) + series_name = clean_segment(series, "", 80) + if series_name: + idx = coerce_series_index(series_index) + sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5] + return Path("comics") / pub / auth / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}{comics_ext}" return Path("comics") / pub / auth / f"{ttl}{comics_ext}" diff --git a/containers/novela/routers/grabber.py b/containers/novela/routers/grabber.py index ca54113..ef96d63 100644 --- a/containers/novela/routers/grabber.py +++ b/containers/novela/routers/grabber.py @@ -293,7 +293,7 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send) tags = list(book.get("tags", [])) if len(book["chapters"]) < 4 and "Shorts" not in tags: tags.append("Shorts") - status_map = {"Long-Term Hold": "Hiatus"} + status_map = {"Temporary-Hold": "Temporary Hold"} pub_status = status_map.get(book.get("publication_status", ""), book.get("publication_status", "")) series = book.get("series", "") diff --git a/containers/novela/routers/library.py b/containers/novela/routers/library.py index b24ca3f..e0ca3ee 100644 --- a/containers/novela/routers/library.py +++ b/containers/novela/routers/library.py @@ -1,4 +1,5 @@ import base64 +import shutil import uuid from datetime import datetime, timezone from pathlib import Path @@ -142,6 +143,7 @@ async def library_import(files: list[UploadFile] = File(...)): title=meta.get("title") or Path(name).stem, series=meta.get("series", ""), series_index=meta.get("series_index", 0), + series_suffix=meta.get("series_suffix", ""), ext=suffix, ) ) @@ -190,6 +192,45 @@ async def library_delete(filename: str): return {"ok": True} +@router.post("/library/bulk-delete") +async def library_bulk_delete(request: Request): + body = await request.json() + filenames = body.get("filenames", []) + if not isinstance(filenames, list): + return JSONResponse({"error": "filenames must be a list"}, status_code=400) + + deleted: list[str] = [] + skipped: list[str] = [] + + for filename in filenames: + if not isinstance(filename, str): + continue + full = resolve_library_path(filename) + if full is None: + skipped.append(filename) + continue + try: + if full.exists(): + parent = full.parent + full.unlink() + prune_empty_dirs(parent) + deleted.append(filename) + except Exception: + skipped.append(filename) + + if deleted: + placeholders = ", ".join(["%s"] * len(deleted)) + with get_db_conn() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + f"DELETE FROM library WHERE filename IN ({placeholders})", + tuple(deleted), + ) + + return {"ok": True, "deleted": len(deleted), "skipped": skipped} + + @router.get("/library/cover-cached/{filename:path}") async def library_cover_cached(filename: str): full = resolve_library_path(filename) @@ -573,6 +614,73 @@ async def stats_page(request: Request): return templates.TemplateResponse(request, "stats.html", {"active": "stats"}) +@router.get("/api/disk") +async def api_disk(): + usage = shutil.disk_usage(str(LIBRARY_DIR)) + pct_used = round(usage.used / usage.total * 100, 1) if usage.total > 0 else 0 + return { + "total": usage.total, + "used": usage.used, + "free": usage.free, + "pct_used": pct_used, + } + + +@router.post("/api/bulk-check-duplicates") +async def bulk_check_duplicates(request: Request): + body = await request.json() + items = body.get("items", []) + if not items or not isinstance(items, list): + return {"duplicates": []} + + parsed = [] + for item in items: + title = item.get("title", "").strip().lower() + author = item.get("author", "").strip().lower() + vol_str = item.get("volume", "").strip() + try: + vol_int = int(vol_str) if vol_str else None + except ValueError: + vol_int = None + parsed.append((title, author, vol_int)) + + # Fetch all DB rows matching any (title, author) pair + title_author_pairs = list({(t, a) for t, a, _ in parsed if t}) + if not title_author_pairs: + return {"duplicates": [False] * len(items)} + + conditions = " OR ".join( + "(LOWER(TRIM(title)) = %s AND LOWER(TRIM(author)) = %s)" for _ in title_author_pairs + ) + params = [v for pair in title_author_pairs for v in pair] + with get_db_conn() as conn: + with conn.cursor() as cur: + cur.execute( + f"SELECT LOWER(TRIM(title)), LOWER(TRIM(author)), series_index" + f" FROM library WHERE {conditions}", + params, + ) + rows = cur.fetchall() + + # (title, author, series_index) for volume-aware lookup + existing_with_vol = {(r[0] or "", r[1] or "", r[2]) for r in rows} + # (title, author) for volume-less lookup + existing_title_author = {(r[0] or "", r[1] or "") for r in rows} + + duplicates = [] + for title, author, vol_int in parsed: + if not title: + duplicates.append(False) + elif vol_int is not None: + # Volume known: only a duplicate when title+author+volume all match + duplicates.append((title, author, vol_int) in existing_with_vol) + else: + # No volume: duplicate if any title+author match exists + duplicates.append((title, author) in existing_title_author) + + return {"duplicates": duplicates} + + @router.get("/api/stats") async def api_stats(): with get_db_conn() as conn: diff --git a/containers/novela/routers/reader.py b/containers/novela/routers/reader.py index d3c17df..e0bb980 100644 --- a/containers/novela/routers/reader.py +++ b/containers/novela/routers/reader.py @@ -428,6 +428,11 @@ def _make_rel_path( # .cbr / .cbz pub = _clean_segment(publisher, "Unknown Publisher", 80) + series_name = _clean_segment(series, "", 80) + if series_name: + idx = _coerce_series_index(series_index) + sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5] + return Path("comics") / pub / auth / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}{ext}" return Path("comics") / pub / auth / f"{ttl}{ext}" diff --git a/containers/novela/static/book.css b/containers/novela/static/book.css index 9c8b19a..ff5242c 100644 --- a/containers/novela/static/book.css +++ b/containers/novela/static/book.css @@ -123,9 +123,10 @@ a.tag-pill:hover { color: var(--accent2); border-color: var(--accent); } display: inline-block; font-family: var(--mono); font-size: 0.62rem; padding: 0.15rem 0.5rem; border-radius: 3px; } -.status-complete { background: rgba(107,170,107,0.12); color: var(--success); border: 1px solid rgba(107,170,107,0.25); } -.status-ongoing { background: rgba(200,160,58,0.12); color: var(--warning); border: 1px solid rgba(200,160,58,0.25); } -.status-hiatus { background: rgba(200,160,58,0.12); color: var(--warning); border: 1px solid rgba(200,160,58,0.25); } +.status-complete { background: rgba(107,170,107,0.12); color: #6baa6b; border: 1px solid rgba(107,170,107,0.25); } +.status-ongoing { background: rgba(74,144,184,0.12); color: #4a90b8; border: 1px solid rgba(74,144,184,0.25); } +.status-temporary-hold { background: rgba(200,160,58,0.12); color: #c8a03a; border: 1px solid rgba(200,160,58,0.25); } +.status-long-term-hold { background: rgba(200,120,58,0.12); color: #c8783a; border: 1px solid rgba(200,120,58,0.25); } /* Progress */ .progress-section { margin-bottom: 1.25rem; } diff --git a/containers/novela/static/library.css b/containers/novela/static/library.css index 67662aa..9074228 100644 --- a/containers/novela/static/library.css +++ b/containers/novela/static/library.css @@ -151,10 +151,13 @@ html, body { align-items: center; justify-content: center; z-index: 2; + background: rgba(15,14,12,0.82); + box-shadow: 0 0 0 2px #0f0e0c; } -.badge-complete { background: rgba(107,170,107,0.18); color: var(--success); } -.badge-ongoing { background: rgba(200,160,58,0.18); color: var(--warning); } -.badge-hiatus { background: rgba(200,160,58,0.18); color: var(--warning); } +.badge-complete { color: #6baa6b; } +.badge-ongoing { color: #4a90b8; } +.badge-temporary-hold { color: #c8a03a; } +.badge-long-term-hold { color: #c8783a; } /* Star: want-to-read top-left */ .btn-star { @@ -164,7 +167,7 @@ html, body { width: 22px; height: 22px; border: none; - background: rgba(15,14,12,0.6); + background: rgba(15,14,12,0.82); border-radius: 50%; display: flex; align-items: center; @@ -174,8 +177,9 @@ html, body { transition: color 0.15s, background 0.15s; padding: 0; z-index: 2; + box-shadow: 0 0 0 2px #0f0e0c; } -.btn-star:hover { color: var(--warning); background: rgba(15,14,12,0.8); } +.btn-star:hover { color: var(--warning); background: rgba(15,14,12,0.82); } .btn-star.starred { color: var(--warning); } /* Book info below cover */ diff --git a/containers/novela/static/library.js b/containers/novela/static/library.js index d789294..71e3a09 100644 --- a/containers/novela/static/library.js +++ b/containers/novela/static/library.js @@ -1,5 +1,19 @@ /* ── Novela — Library page script ─────────────────────────────────────── */ +function statusBadgeHtml(publicationStatus) { + const st = (publicationStatus || '').toLowerCase(); + if (st === 'complete') { + return `
`; + } else if (st === 'ongoing') { + return ``; + } else if (st === 'temporary hold') { + return ``; + } else if (st === 'long-term hold') { + return ``; + } + return ''; +} + let allBooks = []; let currentView = 'all'; let currentParam = null; @@ -460,6 +474,7 @@ async function markSelectedNewAsReviewed(books) { const btn = document.getElementById('btn-mark-reviewed'); if (btn) btn.disabled = true; + let succeeded = false; try { const resp = await fetch('/library/new/mark-reviewed', { method: 'POST', @@ -469,21 +484,19 @@ async function markSelectedNewAsReviewed(books) { const result = await resp.json(); if (!resp.ok || result.error) { alert(result.error || 'Could not mark books as reviewed.'); - return; + } else { + succeeded = true; } - - const selectedSet = new Set(selected); - allBooks.forEach(b => { - if (selectedSet.has(b.filename)) b.needs_review = false; - }); - selected.forEach(f => newSelectedFilenames.delete(f)); - updateCounts(); - renderGrid(); } catch { alert('Could not mark books as reviewed.'); } finally { if (btn) btn.disabled = false; } + + if (succeeded) { + selected.forEach(f => newSelectedFilenames.delete(f)); + await loadLibrary(); + } } function tagValuesByType(book, type) { @@ -919,14 +932,38 @@ function closeBulkDeleteDialog() { async function confirmBulkDelete() { const filenames = [...allSelectedFilenames]; if (!filenames.length) return; - const btn = document.getElementById('bulk-delete-btn'); + + const btn = document.getElementById('bulk-delete-btn'); + const actions = document.getElementById('bulk-delete-actions'); + const progress = document.getElementById('bulk-delete-progress'); + const bar = document.getElementById('bulk-delete-bar'); + const status = document.getElementById('bulk-delete-status'); + if (btn) btn.disabled = true; - for (const filename of filenames) { + if (actions) actions.querySelector('.btn-cancel').disabled = true; + if (progress) progress.style.display = 'block'; + + const BATCH = 20; + const total = filenames.length; + let done = 0; + + for (let i = 0; i < filenames.length; i += BATCH) { + const batch = filenames.slice(i, i + BATCH); try { - await fetch(`/library/file/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + await fetch('/library/bulk-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filenames: batch }), + }); } catch {} + done = Math.min(i + BATCH, total); + if (bar) bar.style.width = Math.round((done / total) * 100) + '%'; + if (status) status.textContent = `${done} / ${total} deleted…`; } + closeBulkDeleteDialog(); + if (progress) progress.style.display = 'none'; + if (bar) bar.style.width = '0%'; allSelectedFilenames.clear(); allLastToggledIndex = null; await loadLibrary(); @@ -984,21 +1021,7 @@ function renderBooksGrid(books) { card.className = 'book-card'; card.id = `card-${cssId(b.filename)}`; - const st = (b.publication_status || '').toLowerCase(); - let statusBadge = ''; - if (st === 'complete') { - statusBadge = `