Merge branch v20260329-01 into main

This commit is contained in:
Ivo Oskamp 2026-04-03 15:15:11 +02:00
commit 26c6c151c6
23 changed files with 1534 additions and 90 deletions

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ from routers.backup import start_backup_scheduler, stop_backup_scheduler
from routers import ( from routers import (
backup_router, backup_router,
builder_router, builder_router,
bulk_import_router,
editor_router, editor_router,
following_router, following_router,
grabber_router, grabber_router,
@ -41,6 +42,7 @@ app.include_router(grabber_router)
app.include_router(settings_router) app.include_router(settings_router)
app.include_router(backup_router) app.include_router(backup_router)
app.include_router(builder_router) app.include_router(builder_router)
app.include_router(bulk_import_router)
app.include_router(following_router) app.include_router(following_router)

View File

@ -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: def run_migrations() -> None:
migrate_create_library() migrate_create_library()
migrate_create_book_tags() migrate_create_book_tags()
@ -309,3 +313,4 @@ def run_migrations() -> None:
migrate_series_suffix() migrate_series_suffix()
migrate_create_builder_drafts() migrate_create_builder_drafts()
migrate_create_authors() migrate_create_authors()
migrate_rename_hiatus()

View File

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

View File

@ -1,5 +1,6 @@
from routers.backup import router as backup_router from routers.backup import router as backup_router
from routers.builder import router as builder_router from routers.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.editor import router as editor_router
from routers.following import router as following_router from routers.following import router as following_router
from routers.grabber import router as grabber_router from routers.grabber import router as grabber_router
@ -15,5 +16,6 @@ __all__ = [
"backup_router", "backup_router",
"settings_router", "settings_router",
"builder_router", "builder_router",
"bulk_import_router",
"following_router", "following_router",
] ]

View File

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

View File

@ -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) pub = clean_segment(publisher, "Unknown Publisher", 80)
auth = clean_segment(author, "Unknown", 80) auth = clean_segment(author, "Unknown", 80)
ttl = clean_segment(title, "Untitled", 140) ttl = clean_segment(title, "Untitled", 140)
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}" return Path("comics") / pub / auth / f"{ttl}{comics_ext}"

View File

@ -293,7 +293,7 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send)
tags = list(book.get("tags", [])) tags = list(book.get("tags", []))
if len(book["chapters"]) < 4 and "Shorts" not in tags: if len(book["chapters"]) < 4 and "Shorts" not in tags:
tags.append("Shorts") tags.append("Shorts")
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", "")) pub_status = status_map.get(book.get("publication_status", ""), book.get("publication_status", ""))
series = book.get("series", "") series = book.get("series", "")

View File

@ -1,4 +1,5 @@
import base64 import base64
import shutil
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -142,6 +143,7 @@ async def library_import(files: list[UploadFile] = File(...)):
title=meta.get("title") or Path(name).stem, title=meta.get("title") or Path(name).stem,
series=meta.get("series", ""), series=meta.get("series", ""),
series_index=meta.get("series_index", 0), series_index=meta.get("series_index", 0),
series_suffix=meta.get("series_suffix", ""),
ext=suffix, ext=suffix,
) )
) )
@ -190,6 +192,45 @@ async def library_delete(filename: str):
return {"ok": True} return {"ok": True}
@router.post("/library/bulk-delete")
async def library_bulk_delete(request: Request):
body = await request.json()
filenames = body.get("filenames", [])
if not isinstance(filenames, list):
return JSONResponse({"error": "filenames must be a list"}, status_code=400)
deleted: list[str] = []
skipped: list[str] = []
for filename in filenames:
if not isinstance(filename, str):
continue
full = resolve_library_path(filename)
if full is None:
skipped.append(filename)
continue
try:
if full.exists():
parent = full.parent
full.unlink()
prune_empty_dirs(parent)
deleted.append(filename)
except Exception:
skipped.append(filename)
if deleted:
placeholders = ", ".join(["%s"] * len(deleted))
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
f"DELETE FROM library WHERE filename IN ({placeholders})",
tuple(deleted),
)
return {"ok": True, "deleted": len(deleted), "skipped": skipped}
@router.get("/library/cover-cached/{filename:path}") @router.get("/library/cover-cached/{filename:path}")
async def library_cover_cached(filename: str): async def library_cover_cached(filename: str):
full = resolve_library_path(filename) full = resolve_library_path(filename)
@ -573,6 +614,73 @@ async def stats_page(request: Request):
return templates.TemplateResponse(request, "stats.html", {"active": "stats"}) return templates.TemplateResponse(request, "stats.html", {"active": "stats"})
@router.get("/api/disk")
async def api_disk():
usage = shutil.disk_usage(str(LIBRARY_DIR))
pct_used = round(usage.used / usage.total * 100, 1) if usage.total > 0 else 0
return {
"total": usage.total,
"used": usage.used,
"free": usage.free,
"pct_used": pct_used,
}
@router.post("/api/bulk-check-duplicates")
async def bulk_check_duplicates(request: Request):
body = await request.json()
items = body.get("items", [])
if not items or not isinstance(items, list):
return {"duplicates": []}
parsed = []
for item in items:
title = item.get("title", "").strip().lower()
author = item.get("author", "").strip().lower()
vol_str = item.get("volume", "").strip()
try:
vol_int = int(vol_str) if vol_str else None
except ValueError:
vol_int = None
parsed.append((title, author, vol_int))
# Fetch all DB rows matching any (title, author) pair
title_author_pairs = list({(t, a) for t, a, _ in parsed if t})
if not title_author_pairs:
return {"duplicates": [False] * len(items)}
conditions = " OR ".join(
"(LOWER(TRIM(title)) = %s AND LOWER(TRIM(author)) = %s)" for _ in title_author_pairs
)
params = [v for pair in title_author_pairs for v in pair]
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
f"SELECT LOWER(TRIM(title)), LOWER(TRIM(author)), series_index"
f" FROM library WHERE {conditions}",
params,
)
rows = cur.fetchall()
# (title, author, series_index) for volume-aware lookup
existing_with_vol = {(r[0] or "", r[1] or "", r[2]) for r in rows}
# (title, author) for volume-less lookup
existing_title_author = {(r[0] or "", r[1] or "") for r in rows}
duplicates = []
for title, author, vol_int in parsed:
if not title:
duplicates.append(False)
elif vol_int is not None:
# Volume known: only a duplicate when title+author+volume all match
duplicates.append((title, author, vol_int) in existing_with_vol)
else:
# No volume: duplicate if any title+author match exists
duplicates.append((title, author) in existing_title_author)
return {"duplicates": duplicates}
@router.get("/api/stats") @router.get("/api/stats")
async def api_stats(): async def api_stats():
with get_db_conn() as conn: with get_db_conn() as conn:

View File

@ -428,6 +428,11 @@ def _make_rel_path(
# .cbr / .cbz # .cbr / .cbz
pub = _clean_segment(publisher, "Unknown Publisher", 80) 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}" return Path("comics") / pub / auth / f"{ttl}{ext}"

View File

@ -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; display: inline-block; font-family: var(--mono); font-size: 0.62rem;
padding: 0.15rem 0.5rem; border-radius: 3px; padding: 0.15rem 0.5rem; border-radius: 3px;
} }
.status-complete { background: rgba(107,170,107,0.12); color: var(--success); border: 1px solid rgba(107,170,107,0.25); } .status-complete { background: rgba(107,170,107,0.12); color: #6baa6b; border: 1px solid rgba(107,170,107,0.25); }
.status-ongoing { background: rgba(200,160,58,0.12); color: var(--warning); border: 1px solid rgba(200,160,58,0.25); } .status-ongoing { background: rgba(74,144,184,0.12); color: #4a90b8; border: 1px solid rgba(74,144,184,0.25); }
.status-hiatus { background: rgba(200,160,58,0.12); color: var(--warning); border: 1px solid rgba(200,160,58,0.25); } .status-temporary-hold { background: rgba(200,160,58,0.12); color: #c8a03a; border: 1px solid rgba(200,160,58,0.25); }
.status-long-term-hold { background: rgba(200,120,58,0.12); color: #c8783a; border: 1px solid rgba(200,120,58,0.25); }
/* Progress */ /* Progress */
.progress-section { margin-bottom: 1.25rem; } .progress-section { margin-bottom: 1.25rem; }

View File

@ -151,10 +151,13 @@ html, body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 2; z-index: 2;
background: rgba(15,14,12,0.82);
box-shadow: 0 0 0 2px #0f0e0c;
} }
.badge-complete { background: rgba(107,170,107,0.18); color: var(--success); } .badge-complete { color: #6baa6b; }
.badge-ongoing { background: rgba(200,160,58,0.18); color: var(--warning); } .badge-ongoing { color: #4a90b8; }
.badge-hiatus { background: rgba(200,160,58,0.18); color: var(--warning); } .badge-temporary-hold { color: #c8a03a; }
.badge-long-term-hold { color: #c8783a; }
/* Star: want-to-read top-left */ /* Star: want-to-read top-left */
.btn-star { .btn-star {
@ -164,7 +167,7 @@ html, body {
width: 22px; width: 22px;
height: 22px; height: 22px;
border: none; border: none;
background: rgba(15,14,12,0.6); background: rgba(15,14,12,0.82);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@ -174,8 +177,9 @@ html, body {
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
padding: 0; padding: 0;
z-index: 2; z-index: 2;
box-shadow: 0 0 0 2px #0f0e0c;
} }
.btn-star:hover { color: var(--warning); background: rgba(15,14,12,0.8); } .btn-star:hover { color: var(--warning); background: rgba(15,14,12,0.82); }
.btn-star.starred { color: var(--warning); } .btn-star.starred { color: var(--warning); }
/* Book info below cover */ /* Book info below cover */

View File

@ -1,5 +1,19 @@
/* ── Novela — Library page script ─────────────────────────────────────── */ /* ── Novela — Library page script ─────────────────────────────────────── */
function statusBadgeHtml(publicationStatus) {
const st = (publicationStatus || '').toLowerCase();
if (st === 'complete') {
return `<div class="badge-status badge-complete" title="Complete"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></div>`;
} else if (st === 'ongoing') {
return `<div class="badge-status badge-ongoing" title="Ongoing"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div>`;
} else if (st === 'temporary hold') {
return `<div class="badge-status badge-temporary-hold" title="Temporary Hold"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="9" x2="14" y2="15"/><circle cx="12" cy="12" r="10"/></svg></div>`;
} else if (st === 'long-term hold') {
return `<div class="badge-status badge-long-term-hold" title="Long-Term Hold"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></div>`;
}
return '';
}
let allBooks = []; let allBooks = [];
let currentView = 'all'; let currentView = 'all';
let currentParam = null; let currentParam = null;
@ -460,6 +474,7 @@ async function markSelectedNewAsReviewed(books) {
const btn = document.getElementById('btn-mark-reviewed'); const btn = document.getElementById('btn-mark-reviewed');
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
let succeeded = false;
try { try {
const resp = await fetch('/library/new/mark-reviewed', { const resp = await fetch('/library/new/mark-reviewed', {
method: 'POST', method: 'POST',
@ -469,21 +484,19 @@ async function markSelectedNewAsReviewed(books) {
const result = await resp.json(); const result = await resp.json();
if (!resp.ok || result.error) { if (!resp.ok || result.error) {
alert(result.error || 'Could not mark books as reviewed.'); 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 { } catch {
alert('Could not mark books as reviewed.'); alert('Could not mark books as reviewed.');
} finally { } finally {
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
} }
if (succeeded) {
selected.forEach(f => newSelectedFilenames.delete(f));
await loadLibrary();
}
} }
function tagValuesByType(book, type) { function tagValuesByType(book, type) {
@ -919,14 +932,38 @@ function closeBulkDeleteDialog() {
async function confirmBulkDelete() { async function confirmBulkDelete() {
const filenames = [...allSelectedFilenames]; const filenames = [...allSelectedFilenames];
if (!filenames.length) return; 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; 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 { 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 {} } 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(); closeBulkDeleteDialog();
if (progress) progress.style.display = 'none';
if (bar) bar.style.width = '0%';
allSelectedFilenames.clear(); allSelectedFilenames.clear();
allLastToggledIndex = null; allLastToggledIndex = null;
await loadLibrary(); await loadLibrary();
@ -984,21 +1021,7 @@ function renderBooksGrid(books) {
card.className = 'book-card'; card.className = 'book-card';
card.id = `card-${cssId(b.filename)}`; card.id = `card-${cssId(b.filename)}`;
const st = (b.publication_status || '').toLowerCase(); const statusBadge = statusBadgeHtml(b.publication_status);
let statusBadge = '';
if (st === 'complete') {
statusBadge = `<div class="badge-status badge-complete" title="Complete">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
</div>`;
} else if (st === 'ongoing') {
statusBadge = `<div class="badge-status badge-ongoing" title="Ongoing">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>`;
} else if (st === 'hiatus') {
statusBadge = `<div class="badge-status badge-hiatus" title="Hiatus">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="9" x2="14" y2="15"/><circle cx="12" cy="12" r="10"/></svg>
</div>`;
}
const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star'; const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star';
const seriesVol = seriesVolLabel(b, idxSeries); const seriesVol = seriesVolLabel(b, idxSeries);
@ -1237,15 +1260,7 @@ function renderSeriesDetail(seriesName) {
const title = bookTitle(b); const title = bookTitle(b);
const cid = cssId(b.filename); const cid = cssId(b.filename);
const st = (b.publication_status || '').toLowerCase(); const statusBadge = statusBadgeHtml(b.publication_status);
let statusBadge = '';
if (st === 'complete') {
statusBadge = `<div class="badge-status badge-complete" title="Complete"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></div>`;
} else if (st === 'ongoing') {
statusBadge = `<div class="badge-status badge-ongoing" title="Ongoing"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div>`;
} else if (st === 'hiatus') {
statusBadge = `<div class="badge-status badge-hiatus" title="Hiatus"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="9" x2="14" y2="15"/><circle cx="12" cy="12" r="10"/></svg></div>`;
}
const bookCard = document.createElement('div'); const bookCard = document.createElement('div');
bookCard.className = 'book-card'; bookCard.className = 'book-card';
@ -1572,21 +1587,7 @@ function renderDuplicatesView() {
card.className = 'book-card'; card.className = 'book-card';
card.id = `card-${cssId(b.filename)}`; card.id = `card-${cssId(b.filename)}`;
const st = (b.publication_status || '').toLowerCase(); const statusBadge = statusBadgeHtml(b.publication_status);
let statusBadge = '';
if (st === 'complete') {
statusBadge = `<div class="badge-status badge-complete" title="Complete">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
</div>`;
} else if (st === 'ongoing') {
statusBadge = `<div class="badge-status badge-ongoing" title="Ongoing">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>`;
} else if (st === 'hiatus') {
statusBadge = `<div class="badge-status badge-hiatus" title="Hiatus">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="9" x2="14" y2="15"/><circle cx="12" cy="12" r="10"/></svg>
</div>`;
}
const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star'; const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star';

View File

@ -89,6 +89,25 @@ html {
.sidebar-bottom { margin-top: auto; } .sidebar-bottom { margin-top: auto; }
.disk-warning {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
border-radius: var(--radius);
font-family: var(--mono);
font-size: 0.7rem;
margin-bottom: 0.5rem;
background: rgba(200,160,58,0.12);
border: 1px solid rgba(200,160,58,0.3);
color: #c8a03a;
}
.disk-warning.critical {
background: rgba(200,90,58,0.12);
border-color: rgba(200,90,58,0.3);
color: #c85a3a;
}
.btn-rescan { .btn-rescan {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -199,6 +199,16 @@
Book Builder Book Builder
</a> </a>
</li> </li>
<li>
<a href="/bulk-import"{% if active == 'bulk_import' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Bulk Import
</a>
</li>
<li> <li>
<a href="/credentials-manager"{% if active == 'credentials' %} class="active"{% endif %}> <a href="/credentials-manager"{% if active == 'credentials' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -237,6 +247,10 @@
</ul> </ul>
<div class="sidebar-bottom"> <div class="sidebar-bottom">
<div class="disk-warning" id="disk-warning" style="display:none">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="disk-warning-text"></span>
</div>
<a href="/backup" class="backup-status-bar" id="backup-status-bar" title="Go to Backup"> <a href="/backup" class="backup-status-bar" id="backup-status-bar" title="Go to Backup">
<span class="backup-dot" id="backup-dot"></span> <span class="backup-dot" id="backup-dot"></span>
<span class="backup-status-text" id="backup-status-text">Backup…</span> <span class="backup-status-text" id="backup-status-text">Backup…</span>
@ -423,8 +437,32 @@
} catch (_) {} } catch (_) {}
} }
async function checkDiskUsage() {
try {
const r = await fetch('/api/disk');
if (!r.ok) return;
const d = await r.json();
const el = document.getElementById('disk-warning');
const txt = document.getElementById('disk-warning-text');
if (!el || !txt) return;
const gb = d.free / (1024 ** 3);
const critical = d.pct_used >= 95 || gb < 0.5;
const warning = d.pct_used >= 85 || gb < 2;
if (critical || warning) {
const freeStr = gb < 1 ? (d.free / (1024 ** 2)).toFixed(0) + ' MB' : gb.toFixed(1) + ' GB';
txt.textContent = `Storage ${d.pct_used}% used · ${freeStr} free`;
el.className = 'disk-warning' + (critical ? ' critical' : '');
el.style.display = 'flex';
} else {
el.style.display = 'none';
}
} catch (_) {}
}
refreshLibraryCounts(); refreshLibraryCounts();
refreshBookmarkCount(); refreshBookmarkCount();
refreshFollowingCount(); refreshFollowingCount();
loadBackupStatus(); loadBackupStatus();
checkDiskUsage();
setInterval(checkDiskUsage, 60_000);
</script> </script>

View File

@ -65,7 +65,7 @@
<span class="meta-label">Status</span> <span class="meta-label">Status</span>
<span class="meta-value"> <span class="meta-value">
{% set st = publication_status | lower %} {% set st = publication_status | lower %}
<span class="status-pill {% if st == 'complete' %}status-complete{% elif st == 'ongoing' %}status-ongoing{% elif st == 'hiatus' %}status-hiatus{% endif %}"> <span class="status-pill {% if st == 'complete' %}status-complete{% elif st == 'ongoing' %}status-ongoing{% elif st == 'temporary hold' %}status-temporary-hold{% elif st == 'long-term hold' %}status-long-term-hold{% endif %}">
{{ publication_status }} {{ publication_status }}
</span> </span>
</span> </span>
@ -252,7 +252,8 @@
<option value=""></option> <option value=""></option>
<option value="Complete">Complete</option> <option value="Complete">Complete</option>
<option value="Ongoing">Ongoing</option> <option value="Ongoing">Ongoing</option>
<option value="Hiatus">Hiatus</option> <option value="Temporary Hold">Temporary Hold</option>
<option value="Long-Term Hold">Long-Term Hold</option>
</select> </select>
</div> </div>
<div class="edit-field"><label class="edit-label">Source URL</label><input class="edit-input" id="ed-url" type="url" placeholder="https://…"/></div> <div class="edit-field"><label class="edit-label">Source URL</label><input class="edit-input" id="ed-url" type="url" placeholder="https://…"/></div>

View File

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

View File

@ -60,7 +60,13 @@
<div class="dialog"> <div class="dialog">
<div class="dialog-title del">Delete books</div> <div class="dialog-title del">Delete books</div>
<p>Delete <strong id="bulk-delete-count"></strong> selected book(s)?<br/>Files will be permanently removed from disk. This cannot be undone.</p> <p>Delete <strong id="bulk-delete-count"></strong> selected book(s)?<br/>Files will be permanently removed from disk. This cannot be undone.</p>
<div class="dialog-actions"> <div id="bulk-delete-progress" style="display:none;margin-bottom:1rem">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:100px;height:5px;overflow:hidden;margin-bottom:0.5rem">
<div id="bulk-delete-bar" style="height:100%;background:var(--error);border-radius:100px;width:0%;transition:width 0.2s ease"></div>
</div>
<div id="bulk-delete-status" style="font-family:var(--mono);font-size:0.75rem;color:var(--text-dim)"></div>
</div>
<div class="dialog-actions" id="bulk-delete-actions">
<button class="btn btn-cancel" onclick="closeBulkDeleteDialog()">Cancel</button> <button class="btn btn-cancel" onclick="closeBulkDeleteDialog()">Cancel</button>
<button class="btn btn-confirm-del" id="bulk-delete-btn" onclick="confirmBulkDelete()">Delete</button> <button class="btn btn-confirm-del" id="bulk-delete-btn" onclick="confirmBulkDelete()">Delete</button>
</div> </div>

View File

@ -28,8 +28,10 @@ All files are stored under `library/` (relative to the app working directory, ma
| 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 | `library/comics/{publisher}/{author}/{title}.cbr` | | CBR (no series) | `library/comics/{publisher}/{author}/{title}.cbr` |
| CBZ | `library/comics/{publisher}/{author}/{title}.cbz` | | CBR (series) | `library/comics/{publisher}/{author}/Series/{series}/{idx:03d} - {title}.cbr` |
| CBZ (no series) | `library/comics/{publisher}/{author}/{title}.cbz` |
| CBZ (series) | `library/comics/{publisher}/{author}/Series/{series}/{idx:03d} - {title}.cbz` |
- Segments are sanitised: special chars stripped, max lengths applied (publisher/author 80, title 140, series 80). - 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.
@ -68,11 +70,14 @@ All files are stored under `library/` (relative to the app working directory, ma
- `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/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/rating/{filename}` — set/clear star rating `{"rating": 0-5}` - `POST /library/rating/{filename}` — set/clear star rating `{"rating": 0-5}`
- `GET /home` — home page - `GET /home` — home page
- `GET /api/home` — home data JSON - `GET /api/home` — home data JSON
- `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}`
- `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).
@ -123,6 +128,12 @@ Home read sections are ordered oldest-first:
- `DELETE /library/bookmarks/{id}` — delete bookmark - `DELETE /library/bookmarks/{id}` — delete bookmark
- `GET /api/bookmarks` — all bookmarks across all books (includes `book_title`, `book_author`) - `GET /api/bookmarks` — all bookmarks across all books (includes `book_title`, `book_author`)
### `routers/bulk_import.py`
- `GET /bulk-import` — Bulk Import page
- `POST /library/bulk-import` — import files with pre-parsed metadata; accepts multipart `files[]`, `rows` (JSON array of per-file metadata), `shared` (JSON with author/publisher/status/genres/tags applied to all files)
Filename parsing is done client-side in `bulk_import.html`. The page uses a free-text `%placeholder%` pattern (e.g. `%series% - %volume% - %title% - %year%`). Available placeholders: `%series%` `%volume%` `%title%` `%year%` `%month%` `%day%` `%author%` `%publisher%` `%ignore%`. Colored chips can be clicked (insert at cursor) or dragged onto the input. Pattern is converted to a regex at parse time. Shared metadata fields override filename-parsed values. Files are uploaded in batches of 5 with a progress bar.
### `routers/editor.py` ### `routers/editor.py`
- `GET /library/editor/{filename}` — EPUB chapter editor page - `GET /library/editor/{filename}` — EPUB chapter editor page
- `GET /api/edit/chapter/{index}/{filename}` — get chapter HTML - `GET /api/edit/chapter/{index}/{filename}` — get chapter HTML
@ -252,6 +263,11 @@ Dropbox settings are managed via the web UI on `/backup`.
- Column visibility persisted in `localStorage` as `novela.all.visibleColumns`. - Column visibility persisted in `localStorage` as `novela.all.visibleColumns`.
- `List` mode has a checkbox column, column visibility filter, and multi-select with `Shift+click` range selection. - `List` mode has a checkbox column, column visibility filter, and multi-select with `Shift+click` range selection.
- `List` mode has a `Delete selected` bulk action: confirms then calls `DELETE /library/file/{filename}` for each selected book. - `List` mode has a `Delete selected` bulk action: confirms then calls `DELETE /library/file/{filename}` for each selected book.
- Publication status values: `Complete`, `Ongoing`, `Temporary Hold`, `Long-Term Hold` (blank = unknown). `Hiatus` was renamed to `Long-Term Hold` via startup migration `migrate_rename_hiatus()`.
- Status badges (top-right of grid card cover): circular icon, dark fill `rgba(15,14,12,0.82)` + `box-shadow: 0 0 0 2px #0f0e0c` ring for visibility on any cover colour. Icon colour per status: Complete=green `#6baa6b`, Ongoing=blue `#4a90b8`, Temporary Hold=amber `#c8a03a`, Long-Term Hold=orange `#c8783a`. `statusBadgeHtml()` in `library.js` is the single source for badge HTML across all grid views.
- Want-to-read star (top-left of grid card cover): same dark fill + ring as status badges.
- Status pills in Book Detail (`book.css`): `status-complete`, `status-ongoing`, `status-temporary-hold`, `status-long-term-hold` — same colour scheme as badges.
- Grabber status mapping (`grabber.py`): `Temporary-Hold` (gayauthors.org) → `Temporary Hold`; `Long-Term Hold` passes through unchanged.
- Star ratings (15) shown under the cover in all grid views: - Star ratings (15) shown under the cover in all grid views:
- Display-only in grid cards (no click, prevents accidental taps while scrolling). - Display-only in grid cards (no click, prevents accidental taps while scrolling).
- Interactive in Book Detail (1.1rem, clickable; clicking the active star clears the rating). - Interactive in Book Detail (1.1rem, clickable; clicking the active star clears the rating).
@ -269,7 +285,7 @@ Dropbox settings are managed via the web UI on `/backup`.
- 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`).
- 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, Hiatus, 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.
- Book Builder (`/builder`): create EPUB books from scratch; drafts stored in `builder_drafts` (JSONB chapters); contenteditable editor with toolbar (bold/italic/underline/blockquote/author-note/scene-break/normalize); autosave every 30 s + Ctrl+S; publish normalizes HTML via `normalize_wysiwyg_html()` and builds EPUB via `build_epub()`. - Book Builder (`/builder`): create EPUB books from scratch; drafts stored in `builder_drafts` (JSONB chapters); contenteditable editor with toolbar (bold/italic/underline/blockquote/author-note/scene-break/normalize); autosave every 30 s + Ctrl+S; publish normalizes HTML via `normalize_wysiwyg_html()` and builds EPUB via `build_epub()`.

View File

@ -1,5 +1,66 @@
# Develop Changelog # Develop Changelog
## 2026-03-29 (14)
- Dockerfile: replaced `unrar-free` with proprietary `unrar` (RARLAB v6.2.6) from Debian non-free — fixes "Failed to read enough data" errors on RAR archives using newer compression methods
## 2026-03-29 (13)
- CBR reader: detect archive format via magic bytes instead of file extension — `.cbr` files that are actually ZIP or 7-zip archives now open correctly; added `py7zr` dependency for 7-zip support
## 2026-03-29 (12)
- Bulk Import: duplicate check now volume-aware — books with the same title+author but a different volume number (e.g. recoloured reprints) are no longer flagged as duplicates; `volume` is included in the API call and matched against `series_index` in the DB
## 2026-03-28 (11)
- Bulk Import: duplicate detection against existing library
- New `POST /api/bulk-check-duplicates` endpoint: case-insensitive title+author+volume match, single SQL query with OR conditions for all pairs
- Duplicate rows highlighted in red in the preview table; a skip checkbox appears per duplicate row
- Stats bar shows "X duplicates · Skip all · Import all" action buttons
- Duplicate rows are skipped by default; user can toggle individual rows or use bulk actions
- `startImport()` filters out skipped rows before batching; skipped files appear in the result summary as "Duplicate skipped"
- If all rows are skipped (nothing to import), result shown immediately without sending any request
## 2026-03-28 (10)
- After "Remove from New" succeeds, the library is now reloaded from the server (`loadLibrary()`) so the New list updates immediately without a manual refresh
## 2026-03-28 (9)
- Bulk delete is now batched: new `POST /library/bulk-delete` endpoint accepts a JSON list of filenames, deletes files and removes DB rows in one query per batch; JS sends 20 files per batch with a progress bar in the confirmation dialog
## 2026-03-28 (8)
- Disk usage warning in sidebar: `GET /api/disk` returns partition usage for the library directory; sidebar polls every 60 s and shows a warning bar (amber ≥ 85% or < 2 GB free, red 95% or < 500 MB free) above the backup status bar
## 2026-03-28 (7)
- Fixed `mark-reviewed` JS: UI (allBooks update + renderGrid) now only runs after confirmed server success; `catch` no longer fires a false error after a successful response where renderGrid throws
## 2026-03-28 (6)
- Bulk Import: rewrote pattern editor from token-pills to free-text `%placeholder%` syntax
- Pattern input: free-text field; placeholders: `%series%` `%volume%` `%title%` `%year%` `%month%` `%day%` `%author%` `%publisher%` `%ignore%`
- Colored chip row: click to insert at cursor position; drag onto input also supported
- Colored live preview below the pattern input; regex-based parser replaces delimiter+token logic
- Shared metadata now wins over filename-parsed values (previously filename won)
- All UI text translated from Dutch to English
## 2026-03-28 (5)
- Status badge (top-right cover) and want-to-read star (top-left cover) now use dark fill `rgba(15,14,12,0.82)` + `box-shadow: 0 0 0 2px #0f0e0c` ring — always readable regardless of cover colour
## 2026-03-28 (4)
- Added `Temporary Hold` status; renamed `Hiatus``Long-Term Hold`
- Startup migration `migrate_rename_hiatus()` converts existing DB records automatically
- Status colours: Complete=green, Ongoing=blue, Temporary Hold=amber, Long-Term Hold=orange
- `statusBadgeHtml()` helper in `library.js` replaces three identical inline badge blocks
- Grabber: `Temporary-Hold` (gayauthors.org) now maps to `Temporary Hold`; `Long-Term Hold` passes through unchanged
- Status dropdowns updated in Book Detail and Bulk Import
## 2026-03-28 (3)
- Added Bulk Import page (`/bulk-import`) for batch importing CBR/CBZ/EPUB/PDF files with filename-based metadata parsing
- Configurable token pattern: Serie, Volume, Titel, Jaar, Auteur, Uitgever, Negeer (in any order)
- Configurable delimiter (default ` - `); year token right-anchored when last so title absorbs overflow segments
- Live test-parse box: type any filename and see how it parses before selecting files
- Shared metadata card: author, publisher, status, genres, tags applied to all files (overridden by pattern tokens or manual edits)
- Preview table: all parsed fields editable via contenteditable cells; warning indicator for rows with fewer segments than tokens; sorted by filename
- Batch upload in groups of 5 with progress bar (N / total files processed)
- Result summary with list of skipped files and reasons
- New `routers/bulk_import.py`: `GET /bulk-import`, `POST /library/bulk-import`
- Sidebar link under Tools between Book Builder and Credentials
This file tracks changes on the `develop` line. This file tracks changes on the `develop` line.
`changelog.md` can later be used for release summaries. `changelog.md` can later be used for release summaries.

1
version.txt Normal file
View File

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