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[^>]*>(.*?)(?: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 => `| ${esc(c.label)} | `).join('')}
+
+
+
+ ${books.map(b => `
+
+ ${cols.map(c => {
+ const value = newCellText(b, c.id);
+ if (c.id === 'title') return `| ${esc(value)} | `;
+ if (c.id === 'has_cover') return `${esc(value)} | `;
+ return `${esc(value)} | `;
+ }).join('')}
+
+ `).join('')}
+
+
+
`;
+
+ 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') %}
Edit EPUB
+ {% endif %}