diff --git a/containers/novela/migrations.py b/containers/novela/migrations.py index ef42d19..b716753 100644 --- a/containers/novela/migrations.py +++ b/containers/novela/migrations.py @@ -193,6 +193,14 @@ def migrate_create_backup_log() -> None: ) +def migrate_add_rating() -> None: + _exec("ALTER TABLE library ADD COLUMN IF NOT EXISTS rating SMALLINT NOT NULL DEFAULT 0") + + +def migrate_remove_cover_missing_tag() -> None: + _exec("DELETE FROM book_tags WHERE tag = 'Cover Missing' AND tag_type = 'tag'") + + def migrate_create_perf_indexes() -> None: # Match library list sorting and common filters. _exec( @@ -238,3 +246,5 @@ def run_migrations() -> None: migrate_create_backup_log() migrate_create_perf_indexes() migrate_seed_break_patterns() + migrate_add_rating() + migrate_remove_cover_missing_tag() diff --git a/containers/novela/routers/common.py b/containers/novela/routers/common.py index 493ddb6..ec89759 100644 --- a/containers/novela/routers/common.py +++ b/containers/novela/routers/common.py @@ -246,11 +246,34 @@ def scan_epub(path: Path) -> dict: for s in re.findall(r"<(?:dc:)?subject[^>]*>(.*?)", opf, re.DOTALL | re.IGNORECASE) if s.strip() ] + m = re.search(r']*name="novela:rating"[^>]*content="([^"]+)"', opf, re.IGNORECASE) + if m: + try: + out["rating"] = max(0, min(5, int(m.group(1)))) + except Exception: + pass except Exception: pass return out +def scan_cbz_rating(path: Path) -> int: + """Read NovelaRating from ComicInfo.xml inside a CBZ (ZIP) file.""" + try: + with zf.ZipFile(path, "r") as z: + names = {n.lower(): n for n in z.namelist()} + ci_key = names.get("comicinfo.xml") + if ci_key is None: + return 0 + xml = z.read(ci_key).decode("utf-8", errors="replace") + m = re.search(r"(\d+)", xml) + if m: + return max(0, min(5, int(m.group(1)))) + except Exception: + pass + return 0 + + def scan_media(path: Path) -> dict: mt = media_type_from_suffix(path) if mt == "epub": @@ -271,6 +294,8 @@ def scan_media(path: Path) -> dict: "publish_date": "", "subjects": [], } + if path.suffix.lower() == ".cbz": + meta["rating"] = scan_cbz_rating(path) else: meta = {} meta["media_type"] = mt @@ -283,8 +308,8 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N """ INSERT INTO library (filename, media_type, title, author, publisher, has_cover, series, series_index, publication_status, source_url, - publish_date, description, needs_review, want_to_read, updated_at) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, NOW()) + publish_date, description, needs_review, want_to_read, rating, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, %s, NOW()) ON CONFLICT (filename) DO UPDATE SET media_type = EXCLUDED.media_type, title = COALESCE(NULLIF(EXCLUDED.title, ''), library.title), @@ -297,6 +322,7 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), library.source_url), publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date), description = COALESCE(NULLIF(EXCLUDED.description, ''), library.description), + rating = CASE WHEN EXCLUDED.rating > 0 THEN EXCLUDED.rating ELSE library.rating END, updated_at = NOW() """, ( @@ -313,6 +339,7 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N meta.get("publish_date") or None, meta.get("description", ""), bool(meta.get("needs_review", False)), + max(0, min(5, int(meta.get("rating", 0) or 0))), ), ) @@ -348,7 +375,8 @@ def list_library_json() -> list[dict]: rp.progress, rp.cfi, rp.page, COALESCE(rs.read_count, 0)::int AS read_count, rs.last_read, - (cc.filename IS NOT NULL) AS has_cached_cover + (cc.filename IS NOT NULL) AS has_cached_cover, + l.rating FROM library l LEFT JOIN reading_progress rp ON rp.filename = l.filename LEFT JOIN ( @@ -392,29 +420,12 @@ def list_library_json() -> list[dict]: "read_count": r[16] or 0, "last_read": r[17].isoformat() if r[17] else None, "tags": tag_map.get(r[0], []), + "rating": r[19] or 0, } ) return out -def ensure_cover_missing_tag(conn, filename: str, has_cover: bool) -> None: - with conn.cursor() as cur: - if has_cover: - cur.execute( - "DELETE FROM book_tags WHERE filename = %s AND tag = 'Cover Missing' AND tag_type = 'tag'", - (filename,), - ) - return - cur.execute( - """ - INSERT INTO book_tags (filename, tag, tag_type) - VALUES (%s, 'Cover Missing', 'tag') - ON CONFLICT (filename, tag, tag_type) DO NOTHING - """, - (filename,), - ) - - def normalize_site(raw: str) -> str: raw = (raw or "").strip() if "://" in raw: diff --git a/containers/novela/routers/grabber.py b/containers/novela/routers/grabber.py index c7f540a..43396a8 100644 --- a/containers/novela/routers/grabber.py +++ b/containers/novela/routers/grabber.py @@ -18,7 +18,6 @@ from epub import detect_image_format, make_chapter_xhtml, make_epub from routers.common import ( LIBRARY_DIR, ensure_cover_cache_for_book, - ensure_cover_missing_tag, ensure_unique_rel_path, make_rel_path, normalize_site, @@ -274,9 +273,6 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send) tags = list(book.get("tags", [])) if len(book["chapters"]) < 4 and "Shorts" not in tags: tags.append("Shorts") - if cover_data is None and "Cover Missing" not in tags: - tags.append("Cover Missing") - status_map = {"Long-Term Hold": "Hiatus"} pub_status = status_map.get(book.get("publication_status", ""), book.get("publication_status", "")) @@ -416,7 +412,6 @@ async def _run_scrape(job_id: str, url: str, username: str, password: str, send) with get_db_conn() as conn: with conn: upsert_book(conn, rel_filename, book_meta, book_tags) - ensure_cover_missing_tag(conn, rel_filename, bool(book_meta["has_cover"])) ensure_cover_cache_for_book(conn, rel_filename, out_path, "epub") send("done", {"filename": rel_filename, "title": book_title, "chapters": len(chapters)}) diff --git a/containers/novela/routers/library.py b/containers/novela/routers/library.py index 0e4ed23..72e2a6a 100644 --- a/containers/novela/routers/library.py +++ b/containers/novela/routers/library.py @@ -13,7 +13,6 @@ from epub import add_cover_to_epub from routers.common import ( LIBRARY_DIR, ensure_cover_cache_for_book, - ensure_cover_missing_tag, ensure_unique_rel_path, list_library_json, make_cover_thumb_webp, @@ -50,7 +49,6 @@ def _sync_disk_to_db() -> int: continue tags = [(s, "subject") for s in meta.get("subjects", [])] upsert_book(conn, rel, meta, tags) - ensure_cover_missing_tag(conn, rel, bool(meta.get("has_cover"))) if bool(meta.get("has_cover")): ensure_cover_cache_for_book(conn, rel, p, meta["media_type"]) synced += 1 @@ -140,7 +138,6 @@ async def library_import(files: list[UploadFile] = File(...)): meta["needs_review"] = True tags = [(s, "subject") for s in meta.get("subjects", [])] upsert_book(conn, rel_name, meta, tags) - ensure_cover_missing_tag(conn, rel_name, bool(meta.get("has_cover"))) ensure_cover_cache_for_book(conn, rel_name, dest, media_type) imported.append(rel_name) except Exception as e: @@ -269,7 +266,6 @@ async def library_add_cover(filename: str, request: Request): upsert_cover_cache(conn, filename, "image/webp", thumb) except (UnidentifiedImageError, OSError, ValueError): pass - ensure_cover_missing_tag(conn, filename, True) return {"ok": True} @@ -368,7 +364,7 @@ async def api_home(): l.series, l.series_index, l.publication_status, l.media_type, COALESCE(rp.progress, 0) AS progress, - rp.cfi + rp.cfi, l.rating FROM reading_progress rp JOIN library l ON l.filename = rp.filename WHERE rp.progress > 0 @@ -380,7 +376,7 @@ async def api_home(): cur.execute( """ - SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type + SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating FROM library l LEFT JOIN reading_sessions rs ON rs.filename = l.filename LEFT JOIN reading_progress rp ON rp.filename = l.filename @@ -396,7 +392,7 @@ async def api_home(): AND bt.tag = 'Shorts' AND bt.tag_type IN ('tag', 'subject') ) - GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type + GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating ORDER BY RANDOM() """ ) @@ -404,7 +400,7 @@ async def api_home(): cur.execute( """ - SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type + SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating FROM library l LEFT JOIN reading_sessions rs ON rs.filename = l.filename LEFT JOIN reading_progress rp ON rp.filename = l.filename @@ -420,7 +416,7 @@ async def api_home(): AND bt.tag = 'Shorts' AND bt.tag_type IN ('tag', 'subject') ) - GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type + GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating ORDER BY RANDOM() """ ) @@ -429,7 +425,7 @@ async def api_home(): cur.execute( """ SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, - MAX(rs.read_at) AS last_read + MAX(rs.read_at) AS last_read, l.rating FROM library l JOIN reading_sessions rs ON rs.filename = l.filename WHERE COALESCE(l.series, '') = '' @@ -442,7 +438,7 @@ async def api_home(): AND bt.tag = 'Shorts' AND bt.tag_type IN ('tag', 'subject') ) - GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type + GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating ORDER BY MAX(rs.read_at) ASC """ ) @@ -451,7 +447,7 @@ async def api_home(): cur.execute( """ SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, - MAX(rs.read_at) AS last_read + MAX(rs.read_at) AS last_read, l.rating FROM library l JOIN reading_sessions rs ON rs.filename = l.filename WHERE COALESCE(l.series, '') = '' @@ -464,7 +460,7 @@ async def api_home(): AND bt.tag = 'Shorts' AND bt.tag_type IN ('tag', 'subject') ) - GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type + GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type, l.rating ORDER BY MAX(rs.read_at) ASC """ ) @@ -479,6 +475,7 @@ async def api_home(): "has_cover": bool(r[3]), "publication_status": r[4] or "", "media_type": r[5] or "epub", + "rating": r[6] or 0, "progress": 0, "series": "", "series_index": 0, @@ -496,6 +493,7 @@ async def api_home(): "publication_status": r[4] or "", "media_type": r[5] or "epub", "last_read": r[6].isoformat() if r[6] else None, + "rating": r[7] or 0, "progress": 0, "series": "", "series_index": 0, @@ -516,6 +514,7 @@ async def api_home(): "media_type": r[7] or "epub", "progress": r[8] or 0, "progress_cfi": r[9], + "rating": r[10] or 0, } for r in cr_rows ], diff --git a/containers/novela/routers/reader.py b/containers/novela/routers/reader.py index 4adff5a..fc2d14d 100644 --- a/containers/novela/routers/reader.py +++ b/containers/novela/routers/reader.py @@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates from cbr import cbr_get_page from db import get_db_conn from epub import read_epub_file, write_epub_file -from pdf import pdf_render_page +from pdf import pdf_page_count, pdf_render_page from routers.common import LIBRARY_DIR, prune_empty_dirs, resolve_library_path, scan_epub router = APIRouter() @@ -183,6 +183,78 @@ def _tag_local(name: str | None) -> str: return name.split(':', 1)[-1].lower() +def _write_epub_rating(epub_path: Path, rating: int) -> None: + """Write (or remove) novela:rating in the EPUB OPF metadata.""" + with zf.ZipFile(epub_path, "r") as z: + names = set(z.namelist()) + container_xml = z.read("META-INF/container.xml").decode("utf-8", errors="replace") if "META-INF/container.xml" in names else None + opf_path = _find_opf_path(names, container_xml) + if not opf_path or opf_path not in names: + return + opf_xml = z.read(opf_path).decode("utf-8", errors="replace") + + opf = BeautifulSoup(opf_xml, "xml") + metadata = opf.find(lambda t: _tag_local(getattr(t, "name", None)) == "metadata") + if not metadata: + return + + for t in metadata.find_all(lambda t: _tag_local(getattr(t, "name", None)) == "meta"): + if (t.get("name") or "").strip() == "novela:rating": + t.decompose() + + if rating > 0: + nt = opf.new_tag("meta") + nt["name"] = "novela:rating" + nt["content"] = str(rating) + metadata.append(nt) + + _rewrite_epub_entries(epub_path, {opf_path: str(opf).encode("utf-8")}) + + +def _write_cbz_rating(cbz_path: Path, rating: int) -> None: + """Write (or remove) NovelaRating in ComicInfo.xml inside a CBZ.""" + with open(cbz_path, "rb") as f: + original = f.read() + + out = io.BytesIO() + ci_key: str | None = None + ci_data: bytes | None = None + + with zf.ZipFile(io.BytesIO(original), "r") as zin: + names = zin.namelist() + for n in names: + if n.lower() == "comicinfo.xml": + ci_key = n + ci_data = zin.read(n) + break + + xml: str + if ci_data is not None: + xml = ci_data.decode("utf-8", errors="replace") + xml = re.sub(r"\d+\s*", "", xml) + if rating > 0: + xml = xml.replace("", f" {rating}\n") + else: + ci_key = "ComicInfo.xml" + if rating > 0: + xml = f'\n\n {rating}\n\n' + else: + return # nothing to write + + new_ci = xml.encode("utf-8") + with zf.ZipFile(io.BytesIO(original), "r") as zin, zf.ZipFile(out, "w", zf.ZIP_DEFLATED) as zout: + for item in zin.infolist(): + if item.filename.lower() == "comicinfo.xml": + continue + data = zin.read(item.filename) + ctype = zf.ZIP_STORED if item.filename == "mimetype" else zf.ZIP_DEFLATED + zout.writestr(item, data, compress_type=ctype) + zout.writestr(ci_key, new_ci, compress_type=zf.ZIP_DEFLATED) + + with open(cbz_path, "wb") as f: + f.write(out.getvalue()) + + def _sync_epub_metadata( epub_path: Path, *, @@ -331,20 +403,28 @@ def _make_rel_path( title: str, series: str, series_index: int | str | None, + ext: str = ".epub", ) -> Path: - pub_dir = _clean_segment(publisher, "Unknown Publisher", 80) - author_dir = _clean_segment(author, "Unknown Author", 80) - clean_title = _clean_segment(title, "Untitled", 140) - clean_series = _clean_segment(series, "", 120) - if clean_series: - idx = _coerce_series_index(series_index) - filename = f"{idx:03d} - {clean_title}.epub" - return Path(pub_dir) / author_dir / "Series" / clean_series / filename - return Path(pub_dir) / author_dir / "Stories" / f"{clean_title}.epub" + auth = _clean_segment(author, "Unknown Author", 80) + ttl = _clean_segment(title, "Untitled", 140) + + if ext == ".epub": + pub = _clean_segment(publisher, "Unknown Publisher", 80) + series_name = _clean_segment(series, "", 120) + if series_name: + idx = _coerce_series_index(series_index) + return Path("epub") / pub / auth / "Series" / series_name / f"{idx:03d} - {ttl}.epub" + return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub" + + if ext == ".pdf": + return Path("pdf") / auth / f"{ttl}.pdf" + + # .cbr / .cbz + return Path("comics") / auth / f"{ttl}{ext}" def _ensure_unique_rel_path(rel_path: Path, *, exclude: Path | None = None) -> Path: - base = rel_path.with_suffix(".epub") + base = rel_path.with_suffix(rel_path.suffix or ".epub") candidate = base counter = 2 while True: @@ -543,7 +623,8 @@ async def book_detail_page(filename: str, request: Request): cur.execute( """ SELECT title, author, publisher, has_cover, series, series_index, - publication_status, want_to_read, source_url, archived, publish_date, description + publication_status, want_to_read, source_url, archived, publish_date, description, + rating FROM library WHERE filename = %s """, (filename,), @@ -563,6 +644,7 @@ async def book_detail_page(filename: str, request: Request): "archived": lib_row[9] or False, "publish_date": lib_row[10].isoformat() if lib_row[10] else "", "description": lib_row[11] or "", + "rating": lib_row[12] or 0, } # Supplement empty fields from EPUB metadata if not entry["source_url"] or not entry["publish_date"] or not entry["description"]: @@ -579,6 +661,7 @@ async def book_detail_page(filename: str, request: Request): entry.setdefault("archived", False) entry.setdefault("publish_date", "") entry.setdefault("description", "") + entry.setdefault("rating", 0) cur.execute( "SELECT tag, tag_type FROM book_tags WHERE filename = %s ORDER BY tag_type, tag", @@ -640,6 +723,7 @@ async def book_detail_page(filename: str, request: Request): "last_read": last_read, "progress": progress, "cfi": cfi, + "rating": entry.get("rating", 0), }) @@ -679,6 +763,7 @@ async def book_update(filename: str, request: Request): publisher = body.get("publisher", "") series = body.get("series", "") series_index = _coerce_series_index(body.get("series_index", 1)) + ext = old_path.suffix.lower() target_rel = _make_rel_path( publisher=publisher, @@ -686,33 +771,33 @@ async def book_update(filename: str, request: Request): title=title, series=series, series_index=series_index, + ext=ext, ) target_rel = _ensure_unique_rel_path(target_rel, exclude=old_path) new_filename = target_rel.as_posix() new_path = (LIBRARY_DIR / target_rel).resolve() moved = False - old_parent_to_prune: Path | None = None if new_path != old_path: new_path.parent.mkdir(parents=True, exist_ok=True) old_path.replace(new_path) moved = True - old_parent_to_prune = old_path.parent try: - _sync_epub_metadata( - new_path, - title=title, - author=author, - publisher=publisher, - publication_status=body.get("publication_status", ""), - source_url=body.get("source_url", ""), - publish_date=body.get("publish_date", ""), - description=body.get("description", ""), - series=series, - series_index=series_index if series else 0, - subjects=(body.get("genres", []) + body.get("subgenres", []) + body.get("tags", [])), - ) + if ext == ".epub": + _sync_epub_metadata( + new_path, + title=title, + author=author, + publisher=publisher, + publication_status=body.get("publication_status", ""), + source_url=body.get("source_url", ""), + publish_date=body.get("publish_date", ""), + description=body.get("description", ""), + series=series, + series_index=series_index if series else 0, + subjects=(body.get("genres", []) + body.get("subgenres", []) + body.get("tags", [])), + ) with get_db_conn() as conn: with conn: @@ -778,8 +863,7 @@ async def book_update(filename: str, request: Request): rows, ) - if old_parent_to_prune is not None: - prune_empty_dirs(old_parent_to_prune) + prune_empty_dirs(old_path.parent) return JSONResponse({"ok": True, "filename": new_filename, "renamed": new_filename != filename}) except Exception as e: if moved and new_path.exists() and not old_path.exists(): @@ -787,6 +871,42 @@ async def book_update(filename: str, request: Request): return JSONResponse({"error": str(e)}, status_code=500) +@router.post("/library/rating/{filename:path}") +async def set_rating(filename: str, request: Request): + """Set (or clear) a 1-5 star rating for a book. rating=0 removes it.""" + path = resolve_library_path(filename) + if path is None or not path.exists(): + return JSONResponse({"error": "not found"}, status_code=404) + + body = await request.json() + try: + rating = max(0, min(5, int(body.get("rating", 0)))) + except (TypeError, ValueError): + return JSONResponse({"error": "invalid rating"}, status_code=400) + + ext = path.suffix.lower() + if ext == ".epub": + try: + _write_epub_rating(path, rating) + except Exception as e: + return JSONResponse({"error": f"epub write failed: {e}"}, status_code=500) + elif ext == ".cbz": + try: + _write_cbz_rating(path, rating) + except Exception as e: + return JSONResponse({"error": f"cbz write failed: {e}"}, status_code=500) + + with get_db_conn() as conn: + with conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE library SET rating = %s, updated_at = NOW() WHERE filename = %s", + (rating, filename), + ) + + return JSONResponse({"ok": True, "rating": rating}) + + @router.get("/library/read/{filename:path}", response_class=HTMLResponse) async def reader_page(filename: str, request: Request): path = resolve_library_path(filename) @@ -799,12 +919,26 @@ async def reader_page(filename: str, request: Request): cur.execute("SELECT title FROM library WHERE filename = %s", (filename,)) row = cur.fetchone() title = row[0] if row and row[0] else filename + fmt = path.suffix.lower().lstrip(".") return templates.TemplateResponse(request, "reader.html", { "filename": filename, "title": title, + "format": fmt, "epub_url": f"/library/epub/{filename}", }) + +@router.get("/api/pdf/info/{filename:path}") +async def pdf_info(filename: str): + path = resolve_library_path(filename) + if path is None or not path.exists(): + return JSONResponse({"error": "not found"}, status_code=404) + try: + count = pdf_page_count(path) + return JSONResponse({"page_count": count}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + @router.get("/library/pdf/{filename:path}") async def library_pdf_page(filename: str, page: int = 0, dpi: int = 150): path = resolve_library_path(filename) diff --git a/containers/novela/static/book.css b/containers/novela/static/book.css index 9fae22f..9c8b19a 100644 --- a/containers/novela/static/book.css +++ b/containers/novela/static/book.css @@ -36,6 +36,36 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil .cover-wrap img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } .cover-wrap canvas { width: 100%; height: 100%; display: block; } +/* Star rating */ +.star-row { + display: flex; + gap: 0.15rem; + margin-top: 0.6rem; + margin-bottom: 0.1rem; + justify-content: center; +} + +.star { + font-size: 1.1rem; + color: rgba(200, 160, 58, 0.25); + cursor: default; + line-height: 1; + transition: color 0.1s; + user-select: none; +} + +.star.filled { + color: #c8a03a; +} + +.star-row.interactive .star { + cursor: pointer; +} + +.star-row.interactive:hover .star { + color: #a07828; +} + /* Want to Read star under cover */ .btn-wtr { display: flex; align-items: center; gap: 0.5rem; diff --git a/containers/novela/static/book.js b/containers/novela/static/book.js index e5efa42..6723aeb 100644 --- a/containers/novela/static/book.js +++ b/containers/novela/static/book.js @@ -52,6 +52,27 @@ if (BOOK.has_cover) { else if (img) img.onload = () => canvas.style.display = 'none'; } +// ── Rating ───────────────────────────────────────────────────────────────── + +let currentRating = BOOK.rating || 0; + +async function rateBook(rating) { + const newRating = currentRating === rating ? 0 : rating; + try { + const resp = await fetch(`/library/rating/${encodeURIComponent(filename)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rating: newRating }), + }); + const result = await resp.json(); + if (!resp.ok || result.error) return; + currentRating = result.rating; + document.querySelectorAll('#book-stars .star').forEach((el, idx) => { + el.classList.toggle('filled', idx + 1 <= currentRating); + }); + } catch {} +} + // ── Want to Read toggle ──────────────────────────────────────────────────── async function toggleWtr() { @@ -187,6 +208,11 @@ class PillInput { this._hideDropdown(); } + flush() { + const v = this.input.value.trim(); + if (v) this._add(v); + } + _showDropdown(items) { if (!items.length) { this.dropdown.style.display = 'none'; return; } this.dropdown.innerHTML = items.map(g => @@ -221,7 +247,7 @@ class PillInput { e.preventDefault(); this.ddIndex = Math.max(this.ddIndex - 1, -1); opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex)); - } else if (e.key === 'Enter') { + } else if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); if (this.ddIndex >= 0 && opts[this.ddIndex]) this._add(opts[this.ddIndex].dataset.val); else if (this.input.value.trim()) this._add(this.input.value); @@ -274,6 +300,9 @@ function closeEdit() { } async function saveEdit() { + genreInput.flush(); + subgenreInput.flush(); + tagInput.flush(); const body = { title: document.getElementById('ed-title').value, author: document.getElementById('ed-author').value, diff --git a/containers/novela/static/library.css b/containers/novela/static/library.css index 5edce11..22d19c4 100644 --- a/containers/novela/static/library.css +++ b/containers/novela/static/library.css @@ -455,6 +455,36 @@ html, body { padding-top: 0.8rem; } +/* ── Star rating ────────────────────────────────────────────────────────── */ + +.star-row { + display: flex; + gap: 0.1rem; + margin-top: 0.3rem; + padding: 0 0.1rem; +} + +.star { + font-size: 0.72rem; + color: rgba(200, 160, 58, 0.25); + cursor: default; + line-height: 1; + transition: color 0.1s; + user-select: none; +} + +.star.filled { + color: var(--warning); +} + +.star-row.interactive .star { + cursor: pointer; +} + +.star-row.interactive:hover .star { + color: var(--accent2); +} + /* ── New view controls + list mode ─────────────────────────────────────── */ .new-controls { diff --git a/containers/novela/static/library.js b/containers/novela/static/library.js index 5ec49a8..a9e4b30 100644 --- a/containers/novela/static/library.js +++ b/containers/novela/static/library.js @@ -12,6 +12,8 @@ const MISSING_PUBLISHER_LABEL = 'No publisher'; const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz']; const NEW_VIEW_MODE_KEY = 'novela.new.viewMode'; const NEW_VISIBLE_COLUMNS_KEY = 'novela.new.visibleColumns'; +const ALL_VIEW_MODE_KEY = 'novela.all.viewMode'; +const ALL_VISIBLE_COLUMNS_KEY = 'novela.all.visibleColumns'; const NEW_DEFAULT_COLUMNS = ['publisher', 'author', 'series', 'volume', 'title', 'has_cover', 'updated', 'genres', 'subgenres', 'tags', 'status']; const NEW_COLUMN_DEFS = [ { id: 'publisher', label: 'Publisher' }, @@ -25,12 +27,15 @@ const NEW_COLUMN_DEFS = [ { id: 'subgenres', label: 'Sub-genres' }, { id: 'tags', label: 'Tags' }, { id: 'status', label: 'Status' }, + { id: 'rating', label: 'Rating' }, ]; let newViewMode = loadNewViewMode(); let newVisibleColumns = loadNewVisibleColumns(); let newSelectedFilenames = new Set(); let newLastToggledIndex = null; +let allViewMode = loadAllViewMode(); +let allVisibleColumns = loadAllVisibleColumns(); // ── Placeholder cover generation ─────────────────────────────────────────── @@ -244,7 +249,8 @@ window.addEventListener('popstate', e => { function renderGrid() { const active = activeBooks(); if (currentView !== 'new') hideNewControls(); - if (currentView === 'all') renderBooksGrid(active); + if (currentView !== 'all') hideAllControls(); + if (currentView === 'all') renderAllView(active); else if (currentView === 'wtr') renderBooksGrid(active.filter(b => b.want_to_read)); else if (currentView === 'series') renderSeriesGrid(); else if (currentView === 'series-detail') renderSeriesDetail(currentParam); @@ -454,6 +460,44 @@ function formatUpdated(iso) { return `${y}-${m}-${day}`; } +function starsText(rating) { + const r = rating || 0; + return '★'.repeat(r) + '☆'.repeat(5 - r); +} + +function starsHtml(filename, rating, interactive = false) { + const r = rating || 0; + const id = cssId(filename); + const cls = interactive ? 'star-row interactive' : 'star-row'; + let html = `
`; + for (let i = 1; i <= 5; i++) { + const onclick = interactive ? ` onclick="event.stopPropagation();rateBook('${jsEsc(filename)}',${i})"` : ''; + html += ``; + } + html += '
'; + return html; +} + +async function rateBook(filename, rating) { + const book = allBooks.find(b => b.filename === filename); + const newRating = (book && book.rating === rating) ? 0 : rating; + try { + const resp = await fetch(`/library/rating/${encodeURIComponent(filename)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rating: newRating }), + }); + const result = await resp.json(); + if (!resp.ok || result.error) return; + if (book) book.rating = result.rating; + const id = cssId(filename); + const row = document.getElementById(`stars-${id}`); + if (row) { + row.outerHTML = starsHtml(filename, result.rating); + } + } catch {} +} + function newCellText(book, colId) { if (colId === 'publisher') return publisherDisplayName(bookPublisherKey(book)); if (colId === 'author') return bookAuthor(book); @@ -466,6 +510,7 @@ function newCellText(book, colId) { if (colId === 'tags') return bookPlainTags(book).join(', '); if (colId === 'volume') return book.series_index > 0 ? String(book.series_index) : ''; if (colId === 'status') return book.publication_status || ''; + if (colId === 'rating') return starsText(book.rating); return ''; } @@ -573,6 +618,146 @@ function renderNewBooksView(books) { renderBooksGrid(books); } +// ── All books view (grid/list toggle) ───────────────────────────────────── + +function loadAllViewMode() { + try { + const raw = localStorage.getItem(ALL_VIEW_MODE_KEY); + return raw === 'list' ? 'list' : 'grid'; + } catch { + return 'grid'; + } +} + +function loadAllVisibleColumns() { + try { + const raw = localStorage.getItem(ALL_VISIBLE_COLUMNS_KEY); + if (!raw) return [...NEW_DEFAULT_COLUMNS]; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return [...NEW_DEFAULT_COLUMNS]; + const allowed = new Set(NEW_COLUMN_DEFS.map(c => c.id)); + const saved = new Set(parsed.filter(v => typeof v === 'string' && allowed.has(v))); + const normalized = NEW_COLUMN_DEFS.map(c => c.id).filter(id => saved.has(id)); + if (!normalized.length) return [...NEW_DEFAULT_COLUMNS]; + return normalized; + } catch { + return [...NEW_DEFAULT_COLUMNS]; + } +} + +function persistAllViewMode() { + try { localStorage.setItem(ALL_VIEW_MODE_KEY, allViewMode); } catch {} +} + +function persistAllVisibleColumns() { + try { localStorage.setItem(ALL_VISIBLE_COLUMNS_KEY, JSON.stringify(allVisibleColumns)); } catch {} +} + +function hideAllControls() { + const controls = document.getElementById('all-controls'); + if (!controls) return; + controls.style.display = 'none'; + controls.innerHTML = ''; +} + +function setAllViewMode(mode) { + if (mode !== 'grid' && mode !== 'list') return; + allViewMode = mode; + persistAllViewMode(); + renderGrid(); +} + +function toggleAllColumnsMenu(ev) { + ev?.stopPropagation(); + const menu = document.getElementById('all-columns-menu'); + if (!menu) return; + menu.classList.toggle('visible'); +} + +function toggleAllColumn(columnId) { + const set = new Set(allVisibleColumns); + if (set.has(columnId)) set.delete(columnId); + else set.add(columnId); + const ordered = NEW_COLUMN_DEFS.map(c => c.id).filter(id => set.has(id)); + allVisibleColumns = ordered.length ? ordered : ['title']; + persistAllVisibleColumns(); + renderGrid(); +} + +function renderAllControls() { + const controls = document.getElementById('all-controls'); + if (!controls) return; + controls.style.display = ''; + controls.innerHTML = ` +
+
+ + +
+ ${allViewMode === 'list' ? ` +
+ +
+ ${NEW_COLUMN_DEFS.map(col => ` + + `).join('')} +
+
+ ` : ''} +
`; +} + +function renderAllBooksList(books) { + const container = document.getElementById('grid-container'); + if (!books.length) { + container.innerHTML = '
No books yet. Import EPUB, PDF or CBR/CBZ to get started.
'; + return; + } + const cols = NEW_COLUMN_DEFS.filter(c => allVisibleColumns.includes(c.id)); + container.innerHTML = ` +
+ + + + ${cols.map(c => ``).join('')} + + + + ${books.map(b => ` + + ${cols.map(c => { + const value = newCellText(b, c.id); + if (c.id === 'title') return ``; + if (c.id === 'has_cover') return ``; + return ``; + }).join('')} + + `).join('')} + +
${esc(c.label)}
${esc(value)}${esc(value)}${esc(value)}
+
`; + + container.querySelectorAll('.all-list-row').forEach(row => { + row.addEventListener('click', () => { + const filename = row.getAttribute('data-filename') || ''; + if (!filename) return; + location.href = `/library/book/${encodeURIComponent(filename)}`; + }); + }); +} + +function renderAllView(books) { + renderAllControls(); + if (allViewMode === 'list') { + renderAllBooksList(books); + } else { + renderBooksGrid(books); + } +} + // ── Book grid (All / WTR / Author detail) ───────────────────────────────── function renderBooksGrid(books) { @@ -635,6 +820,7 @@ function renderBooksGrid(books) { ${b.read_count > 0 ? `
${b.read_count}\u00d7
` : ''} ${b.progress > 0 ? `
` : ''} + ${starsHtml(b.filename, b.rating)}
${esc(title)}
${esc(author)}
@@ -864,6 +1050,7 @@ function renderSeriesDetail(seriesName) { ${b.read_count > 0 ? `
${b.read_count}\u00d7
` : ''} ${b.progress > 0 ? `
` : ''}
+ ${starsHtml(b.filename, b.rating)}
${esc(title)}
${esc(author)}
@@ -1021,7 +1208,11 @@ function renderPublishersView() { // ── Genre view ───────────────────────────────────────────────────────────── function renderGenreView(tag) { - const books = activeBooks().filter(b => (b.genres || []).includes(tag)); + const books = activeBooks().filter(b => + bookGenres(b).includes(tag) || + bookSubgenres(b).includes(tag) || + bookPlainTags(b).includes(tag) + ); renderBooksGrid(books); } @@ -1033,7 +1224,9 @@ function renderSearchResults(query) { const books = activeBooks().filter(b => bookTitle(b).toLowerCase().includes(q) || bookAuthor(b).toLowerCase().includes(q) || - (b.genres || []).some(g => g.toLowerCase().includes(q)) + bookGenres(b).some(g => g.toLowerCase().includes(q)) || + bookSubgenres(b).some(g => g.toLowerCase().includes(q)) || + bookPlainTags(b).some(g => g.toLowerCase().includes(q)) ); renderBooksGrid(books); } diff --git a/containers/novela/templates/book.html b/containers/novela/templates/book.html index 750bd45..2d4d297 100644 --- a/containers/novela/templates/book.html +++ b/containers/novela/templates/book.html @@ -25,6 +25,13 @@ {% endif %}
+ {% set r = (rating | default(0)) | int %} +
+ {% for i in range(1, 6) %} + + {% endfor %} +
+ + {% if filename.endswith('.epub') %} @@ -179,6 +187,7 @@ Edit EPUB + {% endif %} + + + + + + @@ -289,6 +312,7 @@