Compare commits
3 Commits
00e75a6106
...
b43366723c
| Author | SHA1 | Date | |
|---|---|---|---|
| b43366723c | |||
| 3d739b4c72 | |||
| 5d83bfccab |
77
README.md
77
README.md
@ -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
|
- 1–5 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`.
|
||||||
|
|||||||
@ -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/*
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,9 @@ 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,
|
||||||
grabber_router,
|
grabber_router,
|
||||||
library_router,
|
library_router,
|
||||||
reader_router,
|
reader_router,
|
||||||
@ -40,6 +42,8 @@ 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.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -278,6 +278,24 @@ def migrate_create_builder_drafts() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_create_authors() -> None:
|
||||||
|
_exec(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS authors (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
url VARCHAR(1000),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_rename_hiatus() -> None:
|
||||||
|
_exec("UPDATE library SET publication_status = 'Long-Term Hold' WHERE publication_status = 'Hiatus'")
|
||||||
|
|
||||||
|
|
||||||
def run_migrations() -> None:
|
def run_migrations() -> None:
|
||||||
migrate_create_library()
|
migrate_create_library()
|
||||||
migrate_create_book_tags()
|
migrate_create_book_tags()
|
||||||
@ -294,3 +312,5 @@ def run_migrations() -> None:
|
|||||||
migrate_create_bookmarks()
|
migrate_create_bookmarks()
|
||||||
migrate_series_suffix()
|
migrate_series_suffix()
|
||||||
migrate_create_builder_drafts()
|
migrate_create_builder_drafts()
|
||||||
|
migrate_create_authors()
|
||||||
|
migrate_rename_hiatus()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
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.grabber import router as grabber_router
|
from routers.grabber import router as grabber_router
|
||||||
from routers.library import router as library_router
|
from routers.library import router as library_router
|
||||||
from routers.reader import router as reader_router
|
from routers.reader import router as reader_router
|
||||||
@ -14,4 +16,6 @@ __all__ = [
|
|||||||
"backup_router",
|
"backup_router",
|
||||||
"settings_router",
|
"settings_router",
|
||||||
"builder_router",
|
"builder_router",
|
||||||
|
"bulk_import_router",
|
||||||
|
"following_router",
|
||||||
]
|
]
|
||||||
|
|||||||
145
containers/novela/routers/bulk_import.py
Normal file
145
containers/novela/routers/bulk_import.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from cbr import cbr_page_count
|
||||||
|
from db import get_db_conn
|
||||||
|
from routers.common import (
|
||||||
|
LIBRARY_DIR,
|
||||||
|
ensure_cover_cache_for_book,
|
||||||
|
ensure_unique_rel_path,
|
||||||
|
make_rel_path,
|
||||||
|
media_type_from_suffix,
|
||||||
|
parse_volume_str,
|
||||||
|
upsert_book,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bulk-import", response_class=HTMLResponse)
|
||||||
|
async def bulk_import_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "bulk_import.html", {"active": "bulk_import"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/bulk-import")
|
||||||
|
async def library_bulk_import(
|
||||||
|
files: list[UploadFile] = File(...),
|
||||||
|
rows: str = Form(...),
|
||||||
|
shared: str = Form("{}"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
rows_data = json.loads(rows)
|
||||||
|
shared_data = json.loads(shared)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400)
|
||||||
|
|
||||||
|
rows_by_name = {r["original_filename"]: r for r in rows_data}
|
||||||
|
shared_author = shared_data.get("author", "")
|
||||||
|
shared_publisher = shared_data.get("publisher", "")
|
||||||
|
shared_status = shared_data.get("status", "")
|
||||||
|
shared_genres = [t.strip() for t in shared_data.get("genres", "").split(",") if t.strip()]
|
||||||
|
shared_tags = [t.strip() for t in shared_data.get("tags", "").split(",") if t.strip()]
|
||||||
|
|
||||||
|
imported: list[str] = []
|
||||||
|
skipped: list[dict] = []
|
||||||
|
|
||||||
|
for upload in files:
|
||||||
|
try:
|
||||||
|
name = upload.filename or "upload.bin"
|
||||||
|
suffix = Path(name).suffix.lower()
|
||||||
|
if suffix not in {".epub", ".pdf", ".cbr", ".cbz"}:
|
||||||
|
skipped.append({"file": name, "reason": "Unsupported file type"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
mt = media_type_from_suffix(Path(name))
|
||||||
|
if not mt:
|
||||||
|
skipped.append({"file": name, "reason": "Could not detect media type"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = await upload.read()
|
||||||
|
if not data:
|
||||||
|
skipped.append({"file": name, "reason": "Empty upload"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
row = rows_by_name.get(name, {})
|
||||||
|
|
||||||
|
title = (row.get("title") or "").strip() or Path(name).stem
|
||||||
|
author = (row.get("author") or "").strip() or shared_author
|
||||||
|
publisher = (row.get("publisher") or "").strip() or shared_publisher
|
||||||
|
series = (row.get("series") or "").strip() or shared_data.get("series", "")
|
||||||
|
series_index, series_suffix = parse_volume_str(row.get("volume") or "")
|
||||||
|
status = (row.get("status") or "").strip() or shared_status
|
||||||
|
|
||||||
|
year = str(row.get("year") or "").strip()
|
||||||
|
publish_date: str | None = f"{year}-01-01" if year.isdigit() and len(year) == 4 else None
|
||||||
|
|
||||||
|
row_genres = [t.strip() for t in (row.get("genres") or "").split(",") if t.strip()]
|
||||||
|
row_tags = [t.strip() for t in (row.get("tags") or "").split(",") if t.strip()]
|
||||||
|
genres = row_genres if row_genres else shared_genres
|
||||||
|
plain_tags = row_tags if row_tags else shared_tags
|
||||||
|
|
||||||
|
tmp = LIBRARY_DIR / f".import-{uuid.uuid4().hex}{suffix}"
|
||||||
|
tmp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp.write_bytes(data)
|
||||||
|
|
||||||
|
rel = ensure_unique_rel_path(
|
||||||
|
make_rel_path(
|
||||||
|
media_type=mt,
|
||||||
|
publisher=publisher,
|
||||||
|
author=author,
|
||||||
|
title=title,
|
||||||
|
series=series,
|
||||||
|
series_index=series_index,
|
||||||
|
series_suffix=series_suffix,
|
||||||
|
ext=suffix,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dest = LIBRARY_DIR / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp.replace(dest)
|
||||||
|
|
||||||
|
rel_name = rel.as_posix()
|
||||||
|
|
||||||
|
has_cover = False
|
||||||
|
try:
|
||||||
|
if mt == "cbr":
|
||||||
|
has_cover = cbr_page_count(dest) > 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"media_type": mt,
|
||||||
|
"title": title,
|
||||||
|
"author": author,
|
||||||
|
"publisher": publisher,
|
||||||
|
"series": series,
|
||||||
|
"series_index": series_index,
|
||||||
|
"series_suffix": series_suffix,
|
||||||
|
"publication_status": status,
|
||||||
|
"publish_date": publish_date,
|
||||||
|
"has_cover": has_cover,
|
||||||
|
"needs_review": True,
|
||||||
|
"description": "",
|
||||||
|
"source_url": "",
|
||||||
|
}
|
||||||
|
tag_tuples = [(g, "genre") for g in genres] + [(t, "tag") for t in plain_tags]
|
||||||
|
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
upsert_book(conn, rel_name, meta, tag_tuples)
|
||||||
|
ensure_cover_cache_for_book(conn, rel_name, dest, mt)
|
||||||
|
|
||||||
|
imported.append(rel_name)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
skipped.append({"file": upload.filename or "upload", "reason": str(e)})
|
||||||
|
finally:
|
||||||
|
await upload.close()
|
||||||
|
|
||||||
|
return {"ok": True, "imported": imported, "skipped": skipped}
|
||||||
@ -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}"
|
||||||
|
|
||||||
|
|
||||||
@ -407,7 +412,10 @@ def list_library_json() -> list[dict]:
|
|||||||
rs.last_read,
|
rs.last_read,
|
||||||
(cc.filename IS NOT NULL) AS has_cached_cover,
|
(cc.filename IS NOT NULL) AS has_cached_cover,
|
||||||
l.rating,
|
l.rating,
|
||||||
COALESCE(l.series_suffix, '') AS series_suffix
|
COALESCE(l.series_suffix, '') AS series_suffix,
|
||||||
|
json_agg(
|
||||||
|
json_build_object('tag', bt.tag, 'tag_type', bt.tag_type)
|
||||||
|
) FILTER (WHERE bt.tag IS NOT NULL) AS tags
|
||||||
FROM library l
|
FROM library l
|
||||||
LEFT JOIN reading_progress rp ON rp.filename = l.filename
|
LEFT JOIN reading_progress rp ON rp.filename = l.filename
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@ -416,16 +424,17 @@ def list_library_json() -> list[dict]:
|
|||||||
GROUP BY filename
|
GROUP BY filename
|
||||||
) rs ON rs.filename = l.filename
|
) rs ON rs.filename = l.filename
|
||||||
LEFT JOIN library_cover_cache cc ON cc.filename = l.filename
|
LEFT JOIN library_cover_cache cc ON cc.filename = l.filename
|
||||||
|
LEFT JOIN book_tags bt ON bt.filename = l.filename
|
||||||
|
GROUP BY l.filename, l.media_type, l.title, l.author, l.publisher, l.has_cover,
|
||||||
|
l.series, l.series_index, l.publication_status, l.want_to_read,
|
||||||
|
l.archived, l.needs_review, l.updated_at,
|
||||||
|
rp.progress, rp.cfi, rp.page,
|
||||||
|
rs.read_count, rs.last_read,
|
||||||
|
cc.filename, l.rating, l.series_suffix
|
||||||
ORDER BY COALESCE(l.publisher, ''), COALESCE(l.author, ''), COALESCE(l.series, ''), l.series_index, COALESCE(l.title, '')
|
ORDER BY COALESCE(l.publisher, ''), COALESCE(l.author, ''), COALESCE(l.series, ''), l.series_index, COALESCE(l.title, '')
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
cur.execute("SELECT filename, tag, tag_type FROM book_tags ORDER BY filename, tag")
|
|
||||||
tags = cur.fetchall()
|
|
||||||
|
|
||||||
tag_map: dict[str, list[dict]] = {}
|
|
||||||
for filename, tag, tag_type in tags:
|
|
||||||
tag_map.setdefault(filename, []).append({"tag": tag, "tag_type": tag_type})
|
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@ -451,7 +460,7 @@ def list_library_json() -> list[dict]:
|
|||||||
"page": r[15],
|
"page": r[15],
|
||||||
"read_count": r[16] or 0,
|
"read_count": r[16] or 0,
|
||||||
"last_read": r[17].isoformat() if r[17] else None,
|
"last_read": r[17].isoformat() if r[17] else None,
|
||||||
"tags": tag_map.get(r[0], []),
|
"tags": r[21] or [],
|
||||||
"rating": r[19] or 0,
|
"rating": r[19] or 0,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
68
containers/novela/routers/following.py
Normal file
68
containers/novela/routers/following.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from db import get_db_conn
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/following", response_class=HTMLResponse)
|
||||||
|
async def following_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "following.html", {"active": "following"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/following")
|
||||||
|
async def get_following():
|
||||||
|
"""Return all distinct library authors with their URL (if any) and book stats."""
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
l.author,
|
||||||
|
COUNT(l.filename)::int AS book_count,
|
||||||
|
MAX(l.created_at) AS last_added,
|
||||||
|
a.url
|
||||||
|
FROM library l
|
||||||
|
LEFT JOIN authors a ON a.name = l.author
|
||||||
|
WHERE l.author IS NOT NULL AND l.author <> '' AND NOT l.archived
|
||||||
|
GROUP BY l.author, a.url
|
||||||
|
ORDER BY l.author
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": r[0],
|
||||||
|
"book_count": r[1],
|
||||||
|
"last_added": r[2].isoformat() if r[2] else None,
|
||||||
|
"url": r[3],
|
||||||
|
}
|
||||||
|
for r in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/following/{author_name:path}")
|
||||||
|
async def set_author_url(author_name: str, request: Request):
|
||||||
|
"""Set or clear the URL for an author (empty url removes the entry)."""
|
||||||
|
author_name = unquote(author_name)
|
||||||
|
body = await request.json()
|
||||||
|
url = (body.get("url") or "").strip()
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if url:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO authors (name, url)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET url = EXCLUDED.url, updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(author_name, url),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute("DELETE FROM authors WHERE name = %s", (author_name,))
|
||||||
|
return {"ok": True}
|
||||||
@ -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", "")
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import base64
|
import base64
|
||||||
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, File, Request, UploadFile
|
from fastapi import APIRouter, File, Request, UploadFile
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from PIL import UnidentifiedImageError
|
from PIL import UnidentifiedImageError
|
||||||
|
|
||||||
@ -69,19 +70,33 @@ async def library_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/library")
|
@router.get("/api/library")
|
||||||
async def api_library(rescan: bool = False, include_file_info: bool = False):
|
async def api_library(
|
||||||
|
request: Request = None,
|
||||||
|
rescan: bool = False,
|
||||||
|
include_file_info: bool = False,
|
||||||
|
):
|
||||||
# Fast path: avoid expensive full disk scan on every library page load.
|
# Fast path: avoid expensive full disk scan on every library page load.
|
||||||
# Use /library/rescan (or ?rescan=true) when a full sync is needed.
|
# Use /library/rescan (or ?rescan=true) when a full sync is needed.
|
||||||
if rescan:
|
if rescan:
|
||||||
_sync_disk_to_db()
|
_sync_disk_to_db()
|
||||||
|
|
||||||
|
# ETag based on row count + latest updated_at — cheap query before full load.
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT COUNT(*), MAX(updated_at) FROM library")
|
||||||
|
_count, _max_ts = cur.fetchone()
|
||||||
|
etag = f'"{_count}-{int(_max_ts.timestamp()) if _max_ts else 0}"'
|
||||||
|
|
||||||
|
if request and request.headers.get("if-none-match") == etag:
|
||||||
|
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||||
|
|
||||||
books = list_library_json()
|
books = list_library_json()
|
||||||
if include_file_info:
|
if include_file_info:
|
||||||
for b in books:
|
for b in books:
|
||||||
p = resolve_library_path(b["filename"])
|
p = resolve_library_path(b["filename"])
|
||||||
if p and p.exists():
|
if p and p.exists():
|
||||||
b.update(relative_file_info(p))
|
b.update(relative_file_info(p))
|
||||||
return books
|
return JSONResponse(content=books, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/library/rescan")
|
@router.post("/library/rescan")
|
||||||
@ -128,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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -176,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)
|
||||||
@ -559,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:
|
||||||
@ -719,5 +841,5 @@ async def api_stats():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/library/list")
|
@router.get("/library/list")
|
||||||
async def library_list_compat():
|
async def library_list_compat(request: Request):
|
||||||
return await api_library()
|
return await api_library(request)
|
||||||
|
|||||||
@ -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}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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;
|
||||||
@ -107,12 +121,41 @@ function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
|||||||
|
|
||||||
// ── Data loading ───────────────────────────────────────────────────────────
|
// ── Data loading ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _libraryETag = null;
|
||||||
|
|
||||||
async function loadLibrary() {
|
async function loadLibrary() {
|
||||||
const resp = await fetch('/library/list');
|
try {
|
||||||
|
const headers = {};
|
||||||
|
if (_libraryETag) headers['If-None-Match'] = _libraryETag;
|
||||||
|
|
||||||
|
const resp = await fetch('/library/list', { headers });
|
||||||
|
|
||||||
|
if (resp.status === 304) {
|
||||||
|
// Data unchanged — skip JSON parse, just re-render current view
|
||||||
|
updateCounts();
|
||||||
|
renderGrid();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
document.getElementById('grid-container').innerHTML =
|
||||||
|
`<div class="empty">Failed to load library (HTTP ${resp.status}). Check server logs.</div>`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = resp.headers.get('ETag');
|
||||||
|
if (etag) _libraryETag = etag;
|
||||||
|
|
||||||
allBooks = await resp.json();
|
allBooks = await resp.json();
|
||||||
updateCounts();
|
updateCounts();
|
||||||
renderGrid();
|
renderGrid();
|
||||||
return true;
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('loadLibrary error:', err);
|
||||||
|
document.getElementById('grid-container').innerHTML =
|
||||||
|
`<div class="empty">Failed to load library: ${String(err)}. Check browser console.</div>`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeBooks() { return allBooks.filter(b => !b.archived); }
|
function activeBooks() { return allBooks.filter(b => !b.archived); }
|
||||||
@ -189,6 +232,7 @@ function _viewUrl(view, param) {
|
|||||||
if (view === 'bookmarks') return '/library#bookmarks';
|
if (view === 'bookmarks') return '/library#bookmarks';
|
||||||
if (view === 'rated') return '/library#rated';
|
if (view === 'rated') return '/library#rated';
|
||||||
if (view === 'duplicates') return '/library#duplicates';
|
if (view === 'duplicates') return '/library#duplicates';
|
||||||
|
if (view === 'incomplete') return '/library#incomplete';
|
||||||
if (view === 'new') return '/library#new';
|
if (view === 'new') return '/library#new';
|
||||||
if (view === 'genre') return '/library#genre/' + encodeURIComponent(param || '');
|
if (view === 'genre') return '/library#genre/' + encodeURIComponent(param || '');
|
||||||
return '/library';
|
return '/library';
|
||||||
@ -204,7 +248,7 @@ function _applyView(view, param) {
|
|||||||
if (si) { si.value = ''; document.getElementById('search-clear').style.display = 'none'; }
|
if (si) { si.value = ''; document.getElementById('search-clear').style.display = 'none'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => {
|
['nav-all','nav-wtr','nav-new','nav-incomplete','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.classList.remove('active');
|
if (el) el.classList.remove('active');
|
||||||
});
|
});
|
||||||
@ -214,6 +258,7 @@ function _applyView(view, param) {
|
|||||||
'authors': 'nav-authors', 'author-detail': 'nav-authors',
|
'authors': 'nav-authors', 'author-detail': 'nav-authors',
|
||||||
'publishers': 'nav-publishers', 'publisher-detail': 'nav-publishers',
|
'publishers': 'nav-publishers', 'publisher-detail': 'nav-publishers',
|
||||||
'new': 'nav-new',
|
'new': 'nav-new',
|
||||||
|
'incomplete': 'nav-incomplete',
|
||||||
'archived': 'nav-archived',
|
'archived': 'nav-archived',
|
||||||
'bookmarks': 'nav-bookmarks',
|
'bookmarks': 'nav-bookmarks',
|
||||||
'rated': 'nav-rated',
|
'rated': 'nav-rated',
|
||||||
@ -236,6 +281,7 @@ function _applyView(view, param) {
|
|||||||
view === 'bookmarks' ? 'Bookmarks' :
|
view === 'bookmarks' ? 'Bookmarks' :
|
||||||
view === 'rated' ? 'Rated' :
|
view === 'rated' ? 'Rated' :
|
||||||
view === 'duplicates' ? 'Duplicates' :
|
view === 'duplicates' ? 'Duplicates' :
|
||||||
|
view === 'incomplete' ? 'Incomplete' :
|
||||||
view === 'genre' ? `Genre: ${param || ''}` :
|
view === 'genre' ? `Genre: ${param || ''}` :
|
||||||
view === 'search' ? `Search: "${param || ''}"` : '';
|
view === 'search' ? `Search: "${param || ''}"` : '';
|
||||||
|
|
||||||
@ -287,6 +333,7 @@ function renderGrid() {
|
|||||||
else if (currentView === 'bookmarks') renderBookmarksView();
|
else if (currentView === 'bookmarks') renderBookmarksView();
|
||||||
else if (currentView === 'rated') renderRatedView();
|
else if (currentView === 'rated') renderRatedView();
|
||||||
else if (currentView === 'duplicates') renderDuplicatesView();
|
else if (currentView === 'duplicates') renderDuplicatesView();
|
||||||
|
else if (currentView === 'incomplete') renderBooksGrid(active.filter(b => (b.publication_status || '').toLowerCase() !== 'complete'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── New view (bulk review + list/grid toggle) ─────────────────────────────
|
// ── New view (bulk review + list/grid toggle) ─────────────────────────────
|
||||||
@ -427,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',
|
||||||
@ -436,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) {
|
||||||
@ -886,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();
|
||||||
@ -901,6 +971,8 @@ async function confirmBulkDelete() {
|
|||||||
|
|
||||||
// ── Book grid (All / WTR / Author detail) ─────────────────────────────────
|
// ── Book grid (All / WTR / Author detail) ─────────────────────────────────
|
||||||
|
|
||||||
|
let _coverObserver = null;
|
||||||
|
|
||||||
function renderBooksGrid(books) {
|
function renderBooksGrid(books) {
|
||||||
const container = document.getElementById('grid-container');
|
const container = document.getElementById('grid-container');
|
||||||
const idxSeries = indexedSeriesSet();
|
const idxSeries = indexedSeriesSet();
|
||||||
@ -911,6 +983,7 @@ function renderBooksGrid(books) {
|
|||||||
currentView === 'archived' ? 'No archived books. Archive a book from its detail page.' :
|
currentView === 'archived' ? 'No archived books. Archive a book from its detail page.' :
|
||||||
currentView === 'new' ? 'No newly imported books waiting for metadata review.' :
|
currentView === 'new' ? 'No newly imported books waiting for metadata review.' :
|
||||||
currentView === 'rated' ? 'No rated books yet. Rate a book from its detail page.' :
|
currentView === 'rated' ? 'No rated books yet. Rate a book from its detail page.' :
|
||||||
|
currentView === 'incomplete' ? 'No incomplete books — all books have Complete status.' :
|
||||||
currentView === 'genre' ? `No books tagged "${esc(currentParam || '')}".` :
|
currentView === 'genre' ? `No books tagged "${esc(currentParam || '')}".` :
|
||||||
currentView === 'search' ? `No results for "${esc(currentParam || '')}".` :
|
currentView === 'search' ? `No results for "${esc(currentParam || '')}".` :
|
||||||
'No books yet. Import EPUB, PDF or CBR/CBZ to get started.'
|
'No books yet. Import EPUB, PDF or CBR/CBZ to get started.'
|
||||||
@ -918,6 +991,25 @@ function renderBooksGrid(books) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset lazy-load observer for this render pass.
|
||||||
|
// Handles both cover <img> elements (data-src) and placeholder <canvas> elements (data-t / data-a).
|
||||||
|
if (_coverObserver) _coverObserver.disconnect();
|
||||||
|
_coverObserver = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue;
|
||||||
|
const el = entry.target;
|
||||||
|
if (el.tagName === 'IMG') {
|
||||||
|
if (el.dataset.src) { el.src = el.dataset.src; delete el.dataset.src; }
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
makePlaceholderCover(el, el.dataset.t || '', el.dataset.a || '');
|
||||||
|
delete el.dataset.t; delete el.dataset.a;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_coverObserver.unobserve(el);
|
||||||
|
}
|
||||||
|
}, { rootMargin: '400px' });
|
||||||
|
|
||||||
const grid = document.createElement('div');
|
const grid = document.createElement('div');
|
||||||
grid.className = 'cover-grid';
|
grid.className = 'cover-grid';
|
||||||
|
|
||||||
@ -929,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);
|
||||||
@ -952,8 +1030,8 @@ function renderBooksGrid(books) {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="cover-wrap" id="wrap-${cssId(b.filename)}">
|
<div class="cover-wrap">
|
||||||
<canvas class="cover-canvas" id="canvas-${cssId(b.filename)}"></canvas>
|
<canvas class="cover-canvas"></canvas>
|
||||||
<button class="${starClass}" id="star-${cssId(b.filename)}"
|
<button class="${starClass}" id="star-${cssId(b.filename)}"
|
||||||
onclick="event.stopPropagation();toggleWtr('${jsEsc(b.filename)}')" title="Want to Read">
|
onclick="event.stopPropagation();toggleWtr('${jsEsc(b.filename)}')" title="Want to Read">
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="${b.want_to_read ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2.5" id="star-svg-${cssId(b.filename)}">
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="${b.want_to_read ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2.5" id="star-svg-${cssId(b.filename)}">
|
||||||
@ -972,37 +1050,33 @@ function renderBooksGrid(books) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
|
card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
|
||||||
|
|
||||||
|
// Single pass: set up cover using local querySelector — no second iteration needed
|
||||||
|
const wrap = card.querySelector('.cover-wrap');
|
||||||
|
const canvas = card.querySelector('.cover-canvas');
|
||||||
|
if (b.has_cover) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'cover-img';
|
||||||
|
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
||||||
|
img.alt = title;
|
||||||
|
if (b.has_cached_cover) canvas.style.display = 'none';
|
||||||
|
img.onload = () => { canvas.style.display = 'none'; };
|
||||||
|
img.onerror = () => { canvas.style.display = 'block'; makePlaceholderCover(canvas, title, author); };
|
||||||
|
img.dataset.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
||||||
|
_coverObserver.observe(img);
|
||||||
|
wrap.insertBefore(img, wrap.firstChild);
|
||||||
|
}
|
||||||
|
if (!b.has_cover || !b.has_cached_cover) {
|
||||||
|
// Defer placeholder drawing until card enters viewport — avoids 1000+ upfront canvas ops
|
||||||
|
canvas.dataset.t = title;
|
||||||
|
canvas.dataset.a = author;
|
||||||
|
_coverObserver.observe(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
container.appendChild(grid);
|
container.appendChild(grid);
|
||||||
|
|
||||||
books.forEach(b => {
|
|
||||||
const author = bookAuthor(b);
|
|
||||||
const title = bookTitle(b);
|
|
||||||
const wrap = document.getElementById(`wrap-${cssId(b.filename)}`);
|
|
||||||
const canvas = document.getElementById(`canvas-${cssId(b.filename)}`);
|
|
||||||
if (b.has_cover) {
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.className = 'cover-img';
|
|
||||||
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
|
||||||
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
|
||||||
img.alt = title;
|
|
||||||
if (b.has_cached_cover) {
|
|
||||||
canvas.style.display = 'none';
|
|
||||||
}
|
|
||||||
img.onload = () => { canvas.style.display = 'none'; };
|
|
||||||
img.onerror = () => {
|
|
||||||
canvas.style.display = 'block';
|
|
||||||
makePlaceholderCover(canvas, title, author);
|
|
||||||
};
|
|
||||||
wrap.insertBefore(img, wrap.firstChild);
|
|
||||||
}
|
|
||||||
if (!b.has_cover || !b.has_cached_cover) {
|
|
||||||
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Series grid ────────────────────────────────────────────────────────────
|
// ── Series grid ────────────────────────────────────────────────────────────
|
||||||
@ -1186,23 +1260,15 @@ 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';
|
||||||
bookCard.style.cursor = 'pointer';
|
bookCard.style.cursor = 'pointer';
|
||||||
bookCard.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
|
bookCard.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
|
||||||
bookCard.innerHTML = `
|
bookCard.innerHTML = `
|
||||||
<div class="cover-wrap" id="wrap-${cid}">
|
<div class="cover-wrap">
|
||||||
<canvas class="cover-canvas" id="canvas-${cid}"></canvas>
|
<canvas class="cover-canvas"></canvas>
|
||||||
${statusBadge}
|
${statusBadge}
|
||||||
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
|
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
|
||||||
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
|
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
|
||||||
@ -1212,21 +1278,10 @@ function renderSeriesDetail(seriesName) {
|
|||||||
<div class="book-title">${esc(title)}</div>
|
<div class="book-title">${esc(title)}</div>
|
||||||
<div class="book-author">${esc(author)}</div>
|
<div class="book-author">${esc(author)}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
wrapper.appendChild(bookCard);
|
|
||||||
}
|
|
||||||
|
|
||||||
grid.appendChild(wrapper);
|
// Single pass: set up cover using local querySelector
|
||||||
});
|
const wrap = bookCard.querySelector('.cover-wrap');
|
||||||
|
const canvas = bookCard.querySelector('.cover-canvas');
|
||||||
container.innerHTML = '';
|
|
||||||
container.appendChild(grid);
|
|
||||||
|
|
||||||
slots.filter(s => !s.missing).forEach(b => {
|
|
||||||
const author = bookAuthor(b);
|
|
||||||
const title = bookTitle(b);
|
|
||||||
const canvas = document.getElementById(`canvas-${cssId(b.filename)}`);
|
|
||||||
const wrap = document.getElementById(`wrap-${cssId(b.filename)}`);
|
|
||||||
if (!canvas) return;
|
|
||||||
if (b.has_cover) {
|
if (b.has_cover) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
||||||
@ -1237,7 +1292,15 @@ function renderSeriesDetail(seriesName) {
|
|||||||
wrap.insertBefore(img, wrap.firstChild);
|
wrap.insertBefore(img, wrap.firstChild);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
||||||
|
|
||||||
|
wrapper.appendChild(bookCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.appendChild(wrapper);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Authors list ───────────────────────────────────────────────────────────
|
// ── Authors list ───────────────────────────────────────────────────────────
|
||||||
@ -1524,27 +1587,13 @@ 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';
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="cover-wrap" id="wrap-${cssId(b.filename)}">
|
<div class="cover-wrap">
|
||||||
<canvas class="cover-canvas" id="canvas-${cssId(b.filename)}"></canvas>
|
<canvas class="cover-canvas"></canvas>
|
||||||
<button class="${starClass}" id="star-${cssId(b.filename)}"
|
<button class="${starClass}" id="star-${cssId(b.filename)}"
|
||||||
onclick="event.stopPropagation();toggleWtr('${jsEsc(b.filename)}')" title="Want to Read">
|
onclick="event.stopPropagation();toggleWtr('${jsEsc(b.filename)}')" title="Want to Read">
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="${b.want_to_read ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2.5" id="star-svg-${cssId(b.filename)}">
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="${b.want_to_read ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2.5" id="star-svg-${cssId(b.filename)}">
|
||||||
@ -1562,37 +1611,31 @@ function renderDuplicatesView() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
|
card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
|
||||||
|
|
||||||
grid.appendChild(card);
|
// Single pass: set up cover using local querySelector
|
||||||
});
|
const wrap = card.querySelector('.cover-wrap');
|
||||||
|
const canvas = card.querySelector('.cover-canvas');
|
||||||
container.appendChild(grid);
|
|
||||||
|
|
||||||
// Second pass: load covers (same as renderBooksGrid)
|
|
||||||
groupBooks.forEach(b => {
|
|
||||||
const author = bookAuthor(b);
|
|
||||||
const title = bookTitle(b);
|
|
||||||
const wrap = document.getElementById(`wrap-${cssId(b.filename)}`);
|
|
||||||
const canvas = document.getElementById(`canvas-${cssId(b.filename)}`);
|
|
||||||
if (b.has_cover) {
|
if (b.has_cover) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.className = 'cover-img';
|
img.className = 'cover-img';
|
||||||
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
||||||
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
|
||||||
img.alt = title;
|
img.alt = title;
|
||||||
if (b.has_cached_cover) {
|
if (b.has_cached_cover) canvas.style.display = 'none';
|
||||||
canvas.style.display = 'none';
|
|
||||||
}
|
|
||||||
img.onload = () => { canvas.style.display = 'none'; };
|
img.onload = () => { canvas.style.display = 'none'; };
|
||||||
img.onerror = () => {
|
img.onerror = () => { canvas.style.display = 'block'; makePlaceholderCover(canvas, title, author); };
|
||||||
canvas.style.display = 'block';
|
img.dataset.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
||||||
makePlaceholderCover(canvas, title, author);
|
_coverObserver.observe(img);
|
||||||
};
|
|
||||||
wrap.insertBefore(img, wrap.firstChild);
|
wrap.insertBefore(img, wrap.firstChild);
|
||||||
}
|
}
|
||||||
if (!b.has_cover || !b.has_cached_cover) {
|
if (!b.has_cover || !b.has_cached_cover) {
|
||||||
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
canvas.dataset.t = title;
|
||||||
|
canvas.dataset.a = author;
|
||||||
|
_coverObserver.observe(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
container.appendChild(grid);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1825,7 +1868,7 @@ document.getElementById('search-input').addEventListener('input', function() {
|
|||||||
if (q) {
|
if (q) {
|
||||||
currentView = 'search';
|
currentView = 'search';
|
||||||
currentParam = q;
|
currentParam = q;
|
||||||
['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => {
|
['nav-all','nav-wtr','nav-new','nav-incomplete','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.classList.remove('active');
|
if (el) el.classList.remove('active');
|
||||||
});
|
});
|
||||||
@ -1888,6 +1931,7 @@ loadLibrary().then(() => {
|
|||||||
else if (hash === 'bookmarks') view = 'bookmarks';
|
else if (hash === 'bookmarks') view = 'bookmarks';
|
||||||
else if (hash === 'rated') view = 'rated';
|
else if (hash === 'rated') view = 'rated';
|
||||||
else if (hash === 'duplicates') view = 'duplicates';
|
else if (hash === 'duplicates') view = 'duplicates';
|
||||||
|
else if (hash === 'incomplete') view = 'incomplete';
|
||||||
else if (hash.startsWith('genre/')) { view = 'genre'; param = decodeURIComponent(hash.slice(6)); }
|
else if (hash.startsWith('genre/')) { view = 'genre'; param = decodeURIComponent(hash.slice(6)); }
|
||||||
history.replaceState({ view, param }, '', _viewUrl(view, param));
|
history.replaceState({ view, param }, '', _viewUrl(view, param));
|
||||||
_applyView(view, param);
|
_applyView(view, param);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -60,6 +60,16 @@
|
|||||||
<span class="sidebar-count" id="count-new"></span>
|
<span class="sidebar-count" id="count-new"></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% if active == 'library' %}#{% else %}/library#incomplete{% endif %}"
|
||||||
|
{% if active == 'library' %}id="nav-incomplete" onclick="switchView('incomplete'); return false;"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
Incomplete
|
||||||
|
<span class="sidebar-count" id="count-incomplete"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% if active == 'library' %}#{% else %}/library#series{% endif %}"
|
<a href="{% if active == 'library' %}#{% else %}/library#series{% endif %}"
|
||||||
{% if active == 'library' %}id="nav-series" onclick="switchView('series'); return false;"{% endif %}>
|
{% if active == 'library' %}id="nav-series" onclick="switchView('series'); return false;"{% endif %}>
|
||||||
@ -153,6 +163,24 @@
|
|||||||
|
|
||||||
<hr class="sidebar-divider"/>
|
<hr class="sidebar-divider"/>
|
||||||
|
|
||||||
|
<div class="sidebar-section-label">Following</div>
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li>
|
||||||
|
<a href="/following"{% if active == 'following' %} class="active"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
<line x1="19" y1="8" x2="19" y2="14"/>
|
||||||
|
<line x1="22" y1="11" x2="16" y2="11"/>
|
||||||
|
</svg>
|
||||||
|
Authors
|
||||||
|
<span class="sidebar-count" id="count-following"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="sidebar-divider"/>
|
||||||
|
|
||||||
<div class="sidebar-section-label">Tools</div>
|
<div class="sidebar-section-label">Tools</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li>
|
<li>
|
||||||
@ -171,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">
|
||||||
@ -209,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>
|
||||||
@ -246,6 +288,7 @@
|
|||||||
const publisherCount = new Set(active.map(b => b.publisher).filter(Boolean)).size;
|
const publisherCount = new Set(active.map(b => b.publisher).filter(Boolean)).size;
|
||||||
const archivedCount = books.filter(b => b.archived).length;
|
const archivedCount = books.filter(b => b.archived).length;
|
||||||
const ratedCount = active.filter(b => b.rating > 0).length;
|
const ratedCount = active.filter(b => b.rating > 0).length;
|
||||||
|
const incompleteCount = active.filter(b => (b.publication_status || '').toLowerCase() !== 'complete').length;
|
||||||
const dupMap = new Map();
|
const dupMap = new Map();
|
||||||
active.forEach(b => {
|
active.forEach(b => {
|
||||||
const key = (b.title || '').trim().toLowerCase() + '|' + (b.author || '').trim().toLowerCase();
|
const key = (b.title || '').trim().toLowerCase() + '|' + (b.author || '').trim().toLowerCase();
|
||||||
@ -270,6 +313,7 @@
|
|||||||
setCount('count-rated', ratedCount);
|
setCount('count-rated', ratedCount);
|
||||||
setCount('count-archived', archivedCount);
|
setCount('count-archived', archivedCount);
|
||||||
setCount('count-duplicates', dupCount);
|
setCount('count-duplicates', dupCount);
|
||||||
|
setCount('count-incomplete', incompleteCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshLibraryCounts() {
|
async function refreshLibraryCounts() {
|
||||||
@ -383,7 +427,42 @@
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshFollowingCount() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/following');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const authors = await resp.json();
|
||||||
|
const el = document.getElementById('count-following');
|
||||||
|
if (el) el.textContent = authors.filter(a => a.url).length || '';
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkDiskUsage() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/disk');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const d = await r.json();
|
||||||
|
const el = document.getElementById('disk-warning');
|
||||||
|
const txt = document.getElementById('disk-warning-text');
|
||||||
|
if (!el || !txt) return;
|
||||||
|
const gb = d.free / (1024 ** 3);
|
||||||
|
const critical = d.pct_used >= 95 || gb < 0.5;
|
||||||
|
const warning = d.pct_used >= 85 || gb < 2;
|
||||||
|
if (critical || warning) {
|
||||||
|
const freeStr = gb < 1 ? (d.free / (1024 ** 2)).toFixed(0) + ' MB' : gb.toFixed(1) + ' GB';
|
||||||
|
txt.textContent = `Storage ${d.pct_used}% used · ${freeStr} free`;
|
||||||
|
el.className = 'disk-warning' + (critical ? ' critical' : '');
|
||||||
|
el.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
el.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
refreshLibraryCounts();
|
refreshLibraryCounts();
|
||||||
refreshBookmarkCount();
|
refreshBookmarkCount();
|
||||||
|
refreshFollowingCount();
|
||||||
loadBackupStatus();
|
loadBackupStatus();
|
||||||
|
checkDiskUsage();
|
||||||
|
setInterval(checkDiskUsage, 60_000);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
953
containers/novela/templates/bulk_import.html
Normal file
953
containers/novela/templates/bulk_import.html
Normal 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 — <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 += ` <span class="cnt-warn">${warnCount} to check</span>`;
|
||||||
|
if (dupCount) {
|
||||||
|
stats += ` <span class="cnt-dup">${dupCount} duplicate${dupCount !== 1 ? 's' : ''}</span>`;
|
||||||
|
stats += ` <span class="dup-actions">`;
|
||||||
|
stats += `<button onclick="setAllDuplicatesSkip(true)">Skip all</button>`;
|
||||||
|
stats += `<button onclick="setAllDuplicatesSkip(false)">Import all</button>`;
|
||||||
|
stats += `</span>`;
|
||||||
|
}
|
||||||
|
statsEl.innerHTML = stats;
|
||||||
|
|
||||||
|
document.getElementById('import-btn-label').textContent =
|
||||||
|
`Import ${importCount} file${importCount !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAllDuplicatesSkip(skip) {
|
||||||
|
parsedRows.forEach(r => { if (r._duplicate) r._skip = skip; });
|
||||||
|
renderPreviewTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Import ─────────────────────────────────────────────────────────────────
|
||||||
|
async function startImport() {
|
||||||
|
if (!selectedFiles.length) return;
|
||||||
|
|
||||||
|
const shared = {
|
||||||
|
author: document.getElementById('shared-author').value.trim(),
|
||||||
|
publisher: document.getElementById('shared-publisher').value.trim(),
|
||||||
|
series: document.getElementById('shared-series').value.trim(),
|
||||||
|
status: document.getElementById('shared-status').value,
|
||||||
|
genres: document.getElementById('shared-genres').value.trim(),
|
||||||
|
tags: document.getElementById('shared-tags').value.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const btn = document.getElementById('import-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
document.getElementById('progress-wrap').classList.add('visible');
|
||||||
|
document.getElementById('result-box').classList.remove('visible');
|
||||||
|
|
||||||
|
// Filter out rows the user chose to skip (duplicates)
|
||||||
|
const activeFiles = selectedFiles.filter((_, i) => !parsedRows[i]?._skip);
|
||||||
|
const activeRows = parsedRows.filter(r => !r._skip);
|
||||||
|
const skippedAsDup = selectedFiles
|
||||||
|
.filter((f, i) => parsedRows[i]?._skip)
|
||||||
|
.map(f => ({ file: f.name, reason: 'Duplicate – skipped' }));
|
||||||
|
|
||||||
|
const total = activeFiles.length;
|
||||||
|
let done = 0;
|
||||||
|
const allImported = [];
|
||||||
|
const allSkipped = [...skippedAsDup];
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
document.getElementById('progress-status').textContent = 'Done.';
|
||||||
|
showResult(0, allSkipped);
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < activeFiles.length; i += BATCH_SIZE) {
|
||||||
|
const batchFiles = activeFiles.slice(i, i + BATCH_SIZE);
|
||||||
|
const batchRows = activeRows.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
batchFiles.forEach(f => fd.append('files', f));
|
||||||
|
fd.append('rows', JSON.stringify(batchRows));
|
||||||
|
fd.append('shared', JSON.stringify(shared));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/library/bulk-import', { method: 'POST', body: fd });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.imported) allImported.push(...data.imported);
|
||||||
|
if (data.skipped) allSkipped.push(...data.skipped);
|
||||||
|
} catch (e) {
|
||||||
|
batchFiles.forEach(f => allSkipped.push({ file: f.name, reason: 'Network error' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
done = Math.min(i + BATCH_SIZE, total);
|
||||||
|
const pct = Math.round((done / total) * 100);
|
||||||
|
document.getElementById('progress-bar').style.width = pct + '%';
|
||||||
|
document.getElementById('progress-status').textContent =
|
||||||
|
`${done} / ${total} files processed…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
document.getElementById('progress-status').textContent = 'Done.';
|
||||||
|
showResult(allImported.length, allSkipped);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(imported, skipped) {
|
||||||
|
const box = document.getElementById('result-box');
|
||||||
|
document.getElementById('result-ok').textContent =
|
||||||
|
`✓ ${imported} file${imported !== 1 ? 's' : ''} imported.`;
|
||||||
|
|
||||||
|
const skippedEl = document.getElementById('result-skipped');
|
||||||
|
if (skipped.length) {
|
||||||
|
let html = `<div style="font-family:var(--mono);font-size:0.75rem;color:var(--text-dim);margin-bottom:0.4rem;">${skipped.length} skipped:</div>`;
|
||||||
|
html += '<div class="skipped-list">';
|
||||||
|
skipped.forEach(s => {
|
||||||
|
html += `<div class="skipped-item"><span class="sk-file" title="${esc(s.file)}">${esc(s.file)}</span><span>${esc(s.reason)}</span></div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
skippedEl.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
skippedEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
box.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToLibrary() {
|
||||||
|
window.location.href = '/library#new';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TextSuggest ────────────────────────────────────────────────────────────
|
||||||
|
class TextSuggest {
|
||||||
|
constructor(inputId, dropdownId, onSelect) {
|
||||||
|
this.input = document.getElementById(inputId);
|
||||||
|
this.dropdown = document.getElementById(dropdownId);
|
||||||
|
this.all = [];
|
||||||
|
this.ddIndex = -1;
|
||||||
|
this.onSelect = onSelect || (() => {});
|
||||||
|
this.input.addEventListener('input', () => this._onInput());
|
||||||
|
this.input.addEventListener('keydown', (e) => this._onKeydown(e));
|
||||||
|
this.input.addEventListener('blur', () => setTimeout(() => this._hide(), 150));
|
||||||
|
}
|
||||||
|
setSuggestions(all) { this.all = all; }
|
||||||
|
_show(items) {
|
||||||
|
if (!items.length) { this._hide(); return; }
|
||||||
|
this.dropdown.innerHTML = items.map(v =>
|
||||||
|
`<div class="suggest-option" data-val="${v.replace(/"/g,'"')}">${v}</div>`
|
||||||
|
).join('');
|
||||||
|
this.dropdown.querySelectorAll('.suggest-option').forEach(el => {
|
||||||
|
el.onmousedown = (e) => { e.preventDefault(); this.input.value = el.dataset.val; this._hide(); this.onSelect(); };
|
||||||
|
});
|
||||||
|
this.dropdown.style.display = 'block';
|
||||||
|
this.ddIndex = -1;
|
||||||
|
}
|
||||||
|
_hide() { this.dropdown.style.display = 'none'; this.ddIndex = -1; }
|
||||||
|
_onInput() {
|
||||||
|
const q = this.input.value.trim().toLowerCase();
|
||||||
|
if (!q) { this._hide(); return; }
|
||||||
|
this._show(this.all.filter(v => v.toLowerCase().includes(q)).slice(0, 40));
|
||||||
|
}
|
||||||
|
_onKeydown(e) {
|
||||||
|
const opts = this.dropdown.querySelectorAll('.suggest-option');
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.ddIndex = Math.min(this.ddIndex + 1, opts.length - 1);
|
||||||
|
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.ddIndex = Math.max(this.ddIndex - 1, -1);
|
||||||
|
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
|
||||||
|
} else if (e.key === 'Enter' && this.ddIndex >= 0 && opts[this.ddIndex]) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.input.value = opts[this.ddIndex].dataset.val;
|
||||||
|
this._hide();
|
||||||
|
this.onSelect();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
this._hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorSuggest = new TextSuggest('shared-author', 'author-dropdown', updatePreview);
|
||||||
|
const publisherSuggest = new TextSuggest('shared-publisher', 'publisher-dropdown', updatePreview);
|
||||||
|
const seriesSuggest = new TextSuggest('shared-series', 'series-dropdown', updatePreview);
|
||||||
|
|
||||||
|
async function loadSuggestions() {
|
||||||
|
const [authors, publishers, series] = await Promise.all([
|
||||||
|
fetch('/api/suggestions?type=author').then(r => r.json()),
|
||||||
|
fetch('/api/suggestions?type=publisher').then(r => r.json()),
|
||||||
|
fetch('/api/suggestions?type=series').then(r => r.json()),
|
||||||
|
]);
|
||||||
|
authorSuggest.setSuggestions(authors);
|
||||||
|
publisherSuggest.setSuggestions(publishers);
|
||||||
|
seriesSuggest.setSuggestions(series);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
|
initChips();
|
||||||
|
renderPatternPreview();
|
||||||
|
loadSuggestions();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
324
containers/novela/templates/following.html
Normal file
324
containers/novela/templates/following.html
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela — Following</title>
|
||||||
|
<link rel="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%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
|
|
||||||
|
.main { margin-left: var(--sidebar); min-height: 100vh; padding: 2rem 2.5rem 4rem; }
|
||||||
|
@media (max-width: 768px) { .main { margin-left: 0; padding: 4rem 1rem 4rem; } }
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 1.75rem; flex-wrap: wrap; gap: 1rem;
|
||||||
|
}
|
||||||
|
.main-title {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs { display: flex; gap: 0.5rem; }
|
||||||
|
.tab {
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; padding: 0.3rem 0.75rem;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--surface); color: var(--text-dim); cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.tab.active { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.tab .cnt { color: var(--text-faint); margin-left: 0.3rem; }
|
||||||
|
|
||||||
|
.author-list { display: flex; flex-direction: column; gap: 0.5rem; max-width: 860px; }
|
||||||
|
|
||||||
|
.author-row {
|
||||||
|
display: flex; align-items: center; gap: 1rem;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 0.75rem 1rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.author-row:hover { border-color: var(--border); }
|
||||||
|
.author-row.has-url:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.author-avatar {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: var(--serif); font-size: 0.95rem; font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-info { flex: 1; min-width: 0; }
|
||||||
|
.author-name-link {
|
||||||
|
font-family: var(--serif); font-size: 0.92rem; color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.author-name-link:hover { color: var(--accent2); }
|
||||||
|
.author-meta {
|
||||||
|
font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-url-area { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||||
|
.url-display {
|
||||||
|
font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim);
|
||||||
|
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.url-display a { color: var(--text-dim); text-decoration: none; }
|
||||||
|
.url-display a:hover { color: var(--accent2); text-decoration: underline; }
|
||||||
|
.no-url-label {
|
||||||
|
font-family: var(--mono); font-size: 0.68rem; color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-visit {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: transparent; color: var(--text-dim); cursor: pointer;
|
||||||
|
white-space: nowrap; transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-visit:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.btn-edit {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
|
||||||
|
border: 1px solid transparent; border-radius: var(--radius);
|
||||||
|
background: transparent; color: var(--text-faint); cursor: pointer;
|
||||||
|
white-space: nowrap; transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-edit:hover { border-color: var(--border); color: var(--text-dim); }
|
||||||
|
|
||||||
|
.url-edit-form { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.url-input {
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; width: 280px;
|
||||||
|
background: var(--surface2); border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius); color: var(--text);
|
||||||
|
padding: 0.25rem 0.5rem; outline: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) { .url-input { width: 160px; } }
|
||||||
|
.btn-save {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
|
||||||
|
border: 1px solid var(--success); border-radius: var(--radius);
|
||||||
|
background: transparent; color: var(--success); cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-save:hover { background: var(--success); color: var(--bg); }
|
||||||
|
.btn-cancel {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: transparent; color: var(--text-dim); cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty, .loading {
|
||||||
|
font-family: var(--mono); font-size: 0.8rem; color: var(--text-dim);
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
<main class="main">
|
||||||
|
<div class="main-header">
|
||||||
|
<div class="main-title">Following</div>
|
||||||
|
<div class="filter-tabs">
|
||||||
|
<button id="tab-following" class="tab active" onclick="setFilter('following')">
|
||||||
|
Following <span class="cnt" id="cnt-following">0</span>
|
||||||
|
</button>
|
||||||
|
<button id="tab-all" class="tab" onclick="setFilter('all')">
|
||||||
|
All Authors <span class="cnt" id="cnt-all">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="author-list"><div class="loading">Loading…</div></div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const COVER_PALETTES = [
|
||||||
|
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],
|
||||||
|
['#2a1a2a','#8a4aaa'],['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],
|
||||||
|
['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
|
||||||
|
];
|
||||||
|
function strHash(s) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
function esc(s) {
|
||||||
|
return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
function timeAgo(isoStr) {
|
||||||
|
if (!isoStr) return '';
|
||||||
|
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';
|
||||||
|
const diff = Math.floor((Date.now() - new Date(s).getTime()) / 1000);
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||||
|
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||||
|
if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
|
||||||
|
if (diff < 2592000) return Math.floor(diff / 604800) + 'w ago';
|
||||||
|
return Math.floor(diff / 2592000) + 'mo ago';
|
||||||
|
}
|
||||||
|
function hostOf(url) {
|
||||||
|
try { return new URL(url).hostname.replace(/^www\./, ''); } catch (_) { return url; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let allAuthors = [];
|
||||||
|
let currentFilter = 'following';
|
||||||
|
|
||||||
|
async function loadAuthors() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/following');
|
||||||
|
allAuthors = await resp.json();
|
||||||
|
} catch (_) {
|
||||||
|
allAuthors = [];
|
||||||
|
}
|
||||||
|
renderList();
|
||||||
|
updateCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounts() {
|
||||||
|
const followingCount = allAuthors.filter(a => a.url).length;
|
||||||
|
document.getElementById('cnt-following').textContent = followingCount;
|
||||||
|
document.getElementById('cnt-all').textContent = allAuthors.length;
|
||||||
|
const sidebarEl = document.getElementById('count-following');
|
||||||
|
if (sidebarEl) sidebarEl.textContent = followingCount || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFilter(f) {
|
||||||
|
currentFilter = f;
|
||||||
|
document.getElementById('tab-following').classList.toggle('active', f === 'following');
|
||||||
|
document.getElementById('tab-all').classList.toggle('active', f === 'all');
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const container = document.getElementById('author-list');
|
||||||
|
const items = currentFilter === 'following'
|
||||||
|
? allAuthors.filter(a => a.url)
|
||||||
|
: allAuthors;
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
container.innerHTML = `<div class="empty">${
|
||||||
|
currentFilter === 'following'
|
||||||
|
? 'No authors followed yet. Switch to "All Authors" to add URLs.'
|
||||||
|
: 'No authors in your library yet.'
|
||||||
|
}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'author-list';
|
||||||
|
items.forEach(a => list.appendChild(makeRow(a)));
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRow(author) {
|
||||||
|
const [bg, fg] = COVER_PALETTES[strHash(author.name) % COVER_PALETTES.length];
|
||||||
|
const initial = (author.name.trim()[0] || '?').toUpperCase();
|
||||||
|
const books = author.book_count;
|
||||||
|
const meta = books + ' book' + (books !== 1 ? 's' : '') + (author.last_added ? ' · ' + timeAgo(author.last_added) : '');
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'author-row' + (author.url ? ' has-url' : '');
|
||||||
|
row.dataset.name = author.name;
|
||||||
|
row.dataset.url = author.url || '';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="author-avatar" style="background:${bg};color:${fg}">${esc(initial)}</div>
|
||||||
|
<div class="author-info">
|
||||||
|
<a class="author-name-link" href="/library#authors/${encodeURIComponent(author.name)}">${esc(author.name)}</a>
|
||||||
|
<div class="author-meta">${esc(meta)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="author-url-area">${urlAreaHtml(author)}</div>`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlAreaHtml(author) {
|
||||||
|
if (author.url) {
|
||||||
|
return `<div class="url-display" title="${esc(author.url)}"><a href="${esc(author.url)}" target="_blank" rel="noopener noreferrer">${esc(hostOf(author.url))}</a></div>
|
||||||
|
<button class="btn-visit" onclick="visitAuthor(this)" title="${esc(author.url)}">↗ Visit</button>
|
||||||
|
<button class="btn-edit" onclick="startEdit(this)">Edit</button>`;
|
||||||
|
}
|
||||||
|
return `<span class="no-url-label">—</span>
|
||||||
|
<button class="btn-edit" onclick="startEdit(this)">+ URL</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visitAuthor(btn) {
|
||||||
|
const row = btn.closest('.author-row');
|
||||||
|
const url = row.dataset.url;
|
||||||
|
if (url) window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(btn) {
|
||||||
|
const row = btn.closest('.author-row');
|
||||||
|
const currentUrl = row.dataset.url || '';
|
||||||
|
const area = row.querySelector('.author-url-area');
|
||||||
|
area.innerHTML = `
|
||||||
|
<div class="url-edit-form">
|
||||||
|
<input class="url-input" type="url" value="${esc(currentUrl)}" placeholder="https://…"/>
|
||||||
|
<button class="btn-save" onclick="saveUrl(this)">Save</button>
|
||||||
|
<button class="btn-cancel" onclick="cancelEdit(this)">Cancel</button>
|
||||||
|
</div>`;
|
||||||
|
const input = area.querySelector('.url-input');
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
input.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') saveUrl(input.nextElementSibling);
|
||||||
|
if (e.key === 'Escape') cancelEdit(input.nextElementSibling.nextElementSibling);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit(btn) {
|
||||||
|
const row = btn.closest('.author-row');
|
||||||
|
const name = row.dataset.name;
|
||||||
|
const author = allAuthors.find(a => a.name === name);
|
||||||
|
if (!author) return;
|
||||||
|
row.querySelector('.author-url-area').innerHTML = urlAreaHtml(author);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUrl(btn) {
|
||||||
|
const row = btn.closest('.author-row');
|
||||||
|
const name = row.dataset.name;
|
||||||
|
const input = row.querySelector('.url-input');
|
||||||
|
const url = (input ? input.value : '').trim();
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/following/' + encodeURIComponent(name), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Failed');
|
||||||
|
} catch (_) {
|
||||||
|
alert('Failed to save URL.');
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const author = allAuthors.find(a => a.name === name);
|
||||||
|
if (author) author.url = url || null;
|
||||||
|
row.dataset.url = url;
|
||||||
|
row.className = 'author-row' + (url ? ' has-url' : '');
|
||||||
|
row.querySelector('.author-url-area').innerHTML = urlAreaHtml(author || { url: url || null });
|
||||||
|
updateCounts();
|
||||||
|
|
||||||
|
if (currentFilter === 'following' && !url) {
|
||||||
|
row.remove();
|
||||||
|
const list = document.querySelector('#author-list .author-list');
|
||||||
|
if (list && !list.children.length) {
|
||||||
|
document.getElementById('author-list').innerHTML =
|
||||||
|
'<div class="empty">No authors followed yet. Switch to "All Authors" to add URLs.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAuthors();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 1–999.
|
- Series index is zero-padded to 3 digits (`001`, `002`, …), clamped to 1–999.
|
||||||
@ -68,16 +70,20 @@ 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).
|
||||||
For a forced sync: `GET /api/library?rescan=true` or `POST /library/rescan`.
|
For a forced sync: `GET /api/library?rescan=true` or `POST /library/rescan`.
|
||||||
`include_file_info=true` is optional for file size/mtime enrichment.
|
`include_file_info=true` is optional for file size/mtime enrichment.
|
||||||
|
ETag caching: response includes `ETag: "{count}-{max_updated_at_unix}"` and `Cache-Control: no-cache`. Client sends `If-None-Match`; server returns `304 Not Modified` when nothing changed.
|
||||||
|
|
||||||
`/api/home` returns:
|
`/api/home` returns:
|
||||||
- `continue_reading`
|
- `continue_reading`
|
||||||
@ -122,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
|
||||||
@ -164,6 +176,18 @@ Home read sections are ordered oldest-first:
|
|||||||
|
|
||||||
Publish flow: all chapters are run through `normalize_wysiwyg_html()`, then `build_epub()` produces an EPUB 2.0 ZIP. The file path is computed via `make_rel_path(media_type="epub", …)`. The book is inserted into the library with `needs_review=True`. The draft is deleted on success.
|
Publish flow: all chapters are run through `normalize_wysiwyg_html()`, then `build_epub()` produces an EPUB 2.0 ZIP. The file path is computed via `make_rel_path(media_type="epub", …)`. The book is inserted into the library with `needs_review=True`. The draft is deleted on success.
|
||||||
|
|
||||||
|
### `routers/following.py`
|
||||||
|
- `GET /following` — Following page (author URL management)
|
||||||
|
- `GET /api/following` — all distinct library authors with URL (if set), book count, and last-added date
|
||||||
|
- `POST /api/following/{author_name}` — set or clear URL for an author (empty `url` removes the record)
|
||||||
|
|
||||||
|
`GET /api/following` returns one entry per non-archived author:
|
||||||
|
```json
|
||||||
|
{ "name": "Author Name", "book_count": 5, "last_added": "2026-03-27T…", "url": "https://…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
URL is stored in the `authors` table (`name` unique, `url`, `created_at`, `updated_at`).
|
||||||
|
|
||||||
### `routers/backup.py`
|
### `routers/backup.py`
|
||||||
- `GET /backup` — backup page
|
- `GET /backup` — backup page
|
||||||
- `GET /api/backup/credentials` — Dropbox settings (includes `app_key_configured` flag)
|
- `GET /api/backup/credentials` — Dropbox settings (includes `app_key_configured` flag)
|
||||||
@ -239,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 (1–5) shown under the cover in all grid views:
|
- Star ratings (1–5) 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).
|
||||||
@ -256,6 +285,8 @@ 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, Temporary Hold, Long-Term Hold, or blank); sidebar counter included.
|
||||||
|
- Following page (`/following`): dedicated page in its own sidebar section between Library and Tools; shows all library authors with their external URL; two tabs — Following (authors with URL set) and All Authors; inline URL editing with keyboard support (Enter = save, Escape = cancel); clicking Visit opens the external URL in a new tab. Author URLs are stored in the `authors` table. Sidebar counter shows number of followed authors.
|
||||||
- Book Builder (`/builder`): create EPUB books from scratch; drafts stored in `builder_drafts` (JSONB chapters); contenteditable editor with toolbar (bold/italic/underline/blockquote/author-note/scene-break/normalize); autosave every 30 s + Ctrl+S; publish normalizes HTML via `normalize_wysiwyg_html()` and builds EPUB via `build_epub()`.
|
- 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()`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -277,9 +308,13 @@ Dropbox settings are managed via the web UI on `/backup`.
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Performance Notes
|
## Performance Notes
|
||||||
- Library load is optimized for large datasets:
|
- Library load is optimized for large datasets (1000+ books):
|
||||||
- `list_library_json()` uses pre-aggregation for `reading_sessions`.
|
- `list_library_json()` uses `json_agg` in the main query to inline tags per book — eliminates a separate `SELECT * FROM book_tags` query and Python merge loop.
|
||||||
- `has_cached_cover` is provided directly via SQL join instead of full cache fetch.
|
- `has_cached_cover` is provided directly via SQL join instead of full cache fetch.
|
||||||
|
- `reading_sessions` is pre-aggregated in a subquery.
|
||||||
|
- ETag on `/api/library`: cheap `COUNT + MAX(updated_at)` query before full load; `304 Not Modified` on cache hit.
|
||||||
|
- Front-end rendering uses `IntersectionObserver` to defer both cover image loading and placeholder canvas drawing until cards enter the viewport — prevents hundreds of simultaneous HTTP requests and canvas operations on initial render.
|
||||||
|
- `renderBooksGrid`, `renderDuplicatesView`, `renderSeriesDetail` all use a single DOM pass: cover `<img>` and `<canvas>` are set up via `card.querySelector` immediately after `innerHTML` is set, eliminating a second full iteration with `document.getElementById` calls.
|
||||||
- Additional migration indexes:
|
- Additional migration indexes:
|
||||||
- `idx_library_sort_coalesce`
|
- `idx_library_sort_coalesce`
|
||||||
- `idx_library_needs_review`
|
- `idx_library_needs_review`
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
# TODO: Performance improvements — Library "All Books" load speed
|
|
||||||
|
|
||||||
Observed: loading all books (especially on hard refresh CTRL+F5) is slow.
|
|
||||||
Root cause analysis identified four bottlenecks, ordered by impact.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1. Lazy cover loading (HIGH impact)
|
|
||||||
**Problem:** On every full render, a `<img>` is created immediately for every book card that has a cover. With 500+ books this fires 500+ simultaneous HTTP requests to `/library/cover-cached/`. The browser throttles these and the server gets hammered.
|
|
||||||
|
|
||||||
**Fix:** Use an `IntersectionObserver` to defer image loading until a card scrolls into the viewport. Cards outside the viewport stay as canvas placeholders until they appear.
|
|
||||||
|
|
||||||
**Affected:** `static/library.js` — `renderBooksGrid()` second pass (lines ~981–1005)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. HTTP caching on `/api/library` (HIGH impact)
|
|
||||||
**Problem:** Every refresh (including soft refresh) fetches the full book list JSON from the server with no caching. No `ETag`, `Last-Modified` or `Cache-Control` headers are set.
|
|
||||||
|
|
||||||
**Fix:** Add an `ETag` header based on a hash of the serialized response (or a DB row count + last `updated_at`). Browser sends `If-None-Match`; server returns `304 Not Modified` when nothing changed — zero data transfer.
|
|
||||||
|
|
||||||
**Affected:** `routers/library.py` — `GET /api/library` endpoint; `static/library.js` — `loadLibrary()`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Double DOM pass in renderBooksGrid (MEDIUM impact)
|
|
||||||
**Problem:** `renderBooksGrid` makes two full iterations over the book list:
|
|
||||||
1. Build all card HTML and append to the grid.
|
|
||||||
2. Query the DOM again for each `canvas-*` and `wrap-*` element to set up image loading.
|
|
||||||
|
|
||||||
This causes two reflows and 500+ `getElementById` calls after the DOM is already populated.
|
|
||||||
|
|
||||||
**Fix:** Combine both passes into one: build the card element, immediately set up the `<img>` element and canvas, then append. No second iteration needed.
|
|
||||||
|
|
||||||
**Affected:** `static/library.js` — `renderBooksGrid()` (lines ~904–1005)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Tags via separate query + Python merge (LOW impact)
|
|
||||||
**Problem:** `list_library_json()` fetches book tags via a separate `SELECT * FROM book_tags` query and then merges them in Python using a dict. For large libraries this means two round-trips and an O(n) in-process merge.
|
|
||||||
|
|
||||||
**Fix:** Use a PostgreSQL JSON aggregation in the main query:
|
|
||||||
```sql
|
|
||||||
COALESCE(
|
|
||||||
json_agg(json_build_object('tag', bt.tag, 'tag_type', bt.tag_type))
|
|
||||||
FILTER (WHERE bt.tag IS NOT NULL),
|
|
||||||
'[]'
|
|
||||||
) AS tags
|
|
||||||
```
|
|
||||||
This returns tags inline per book row, eliminating the second query and the Python merge loop.
|
|
||||||
|
|
||||||
**Affected:** `routers/common.py` — `list_library_json()` (lines ~397–458)
|
|
||||||
@ -1,8 +1,85 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
## 2026-03-28 (2)
|
||||||
|
- Performance: library page now loads instantly for large collections (1000+ books)
|
||||||
|
- `IntersectionObserver` defers both cover image loading and placeholder canvas drawing until cards enter the viewport — eliminates hundreds of upfront canvas ops that blocked the initial render
|
||||||
|
- `ETag` caching on `/library/list`: server returns `304 Not Modified` when nothing changed, client skips JSON parse and re-download
|
||||||
|
- Single DOM pass in `renderBooksGrid`, `renderDuplicatesView`, `renderSeriesDetail`: canvas and img set up via `card.querySelector` immediately after `innerHTML`, removing a second iteration with `document.getElementById` per card
|
||||||
|
- `book_tags` joined via `json_agg` in the main `list_library_json()` query, eliminating a separate `SELECT * FROM book_tags` query and Python merge loop
|
||||||
|
- `loadLibrary` now shows an error message instead of staying stuck on "Loading…" when the fetch or render fails
|
||||||
|
|
||||||
|
## 2026-03-28 (1)
|
||||||
|
- Added Following page (`/following`): track external author URLs outside Library and Tools
|
||||||
|
- New `authors` table: `name` (unique), `url`, `created_at`, `updated_at`
|
||||||
|
- New `routers/following.py`: `GET /following` page, `GET /api/following` (all authors + URL + book count + last added), `POST /api/following/{name}` (set/clear URL)
|
||||||
|
- Sidebar: new Following section between Library and Tools; counter shows number of followed authors
|
||||||
|
- Following page: two tabs — Following (authors with URL) and All Authors; inline URL editing with Enter/Escape keyboard support; Visit button opens external URL in a new tab; author name links to library author view
|
||||||
|
- Added Incomplete view to Library (`#incomplete`): shows all non-archived books where `publication_status ≠ Complete`; sidebar counter included; entry placed after New in the Library section
|
||||||
|
|
||||||
## 2026-03-27 (1)
|
## 2026-03-27 (1)
|
||||||
- Convert page: duplicate warning shown after loading metadata when a book with the same title+author already exists in the library; warning includes a link to the existing book; user can still proceed with conversion
|
- Convert page: duplicate warning shown after loading metadata when a book with the same title+author already exists in the library; warning includes a link to the existing book; user can still proceed with conversion
|
||||||
- Library: added Duplicates section to sidebar (between Rated and Statistics); counter shows total number of books that are part of a duplicate group (same title+author, case-insensitive); Duplicates view groups books by title+author with a subheading per group
|
- Library: added Duplicates section to sidebar (between Rated and Statistics); counter shows total number of books that are part of a duplicate group (same title+author, case-insensitive); Duplicates view groups books by title+author with a subheading per group
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user