Add PDF reader/editor support, fix metadata save and dir cleanup
- PDF reader: page-image rendering via /library/pdf/{filename}?page=N;
new /api/pdf/info/{filename} endpoint returns page count; reader.html
branches on FORMAT (epub/pdf) injected by server
- PDF metadata edit: PATCH /library/book now updates DB for all formats;
_sync_epub_metadata only called for .epub; non-EPUB formats skip file write
- Fix file path on metadata save: _make_rel_path now includes format prefix
(epub/, pdf/, comics/) matching common.make_rel_path used during import;
previously files were moved outside their format directory
- Fix empty dir cleanup: prune_empty_dirs always runs after successful
metadata save, not only when file was moved
- Hide Edit EPUB button for non-EPUB files in book detail
- Docs: TECHNICAL.md and changelog-develop.md updated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e60b86ea7e
commit
92cd301658
@ -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:
|
def migrate_create_perf_indexes() -> None:
|
||||||
# Match library list sorting and common filters.
|
# Match library list sorting and common filters.
|
||||||
_exec(
|
_exec(
|
||||||
@ -238,3 +246,5 @@ def run_migrations() -> None:
|
|||||||
migrate_create_backup_log()
|
migrate_create_backup_log()
|
||||||
migrate_create_perf_indexes()
|
migrate_create_perf_indexes()
|
||||||
migrate_seed_break_patterns()
|
migrate_seed_break_patterns()
|
||||||
|
migrate_add_rating()
|
||||||
|
migrate_remove_cover_missing_tag()
|
||||||
|
|||||||
@ -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)
|
for s in re.findall(r"<(?:dc:)?subject[^>]*>(.*?)</(?:dc:)?subject>", opf, re.DOTALL | re.IGNORECASE)
|
||||||
if s.strip()
|
if s.strip()
|
||||||
]
|
]
|
||||||
|
m = re.search(r'<meta[^>]*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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return out
|
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"<NovelaRating>(\d+)</NovelaRating>", xml)
|
||||||
|
if m:
|
||||||
|
return max(0, min(5, int(m.group(1))))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def scan_media(path: Path) -> dict:
|
def scan_media(path: Path) -> dict:
|
||||||
mt = media_type_from_suffix(path)
|
mt = media_type_from_suffix(path)
|
||||||
if mt == "epub":
|
if mt == "epub":
|
||||||
@ -271,6 +294,8 @@ def scan_media(path: Path) -> dict:
|
|||||||
"publish_date": "",
|
"publish_date": "",
|
||||||
"subjects": [],
|
"subjects": [],
|
||||||
}
|
}
|
||||||
|
if path.suffix.lower() == ".cbz":
|
||||||
|
meta["rating"] = scan_cbz_rating(path)
|
||||||
else:
|
else:
|
||||||
meta = {}
|
meta = {}
|
||||||
meta["media_type"] = mt
|
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,
|
INSERT INTO library (filename, media_type, title, author, publisher, has_cover,
|
||||||
series, series_index, publication_status, source_url,
|
series, series_index, publication_status, source_url,
|
||||||
publish_date, description, needs_review, want_to_read, updated_at)
|
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, NOW())
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, %s, NOW())
|
||||||
ON CONFLICT (filename) DO UPDATE SET
|
ON CONFLICT (filename) DO UPDATE SET
|
||||||
media_type = EXCLUDED.media_type,
|
media_type = EXCLUDED.media_type,
|
||||||
title = COALESCE(NULLIF(EXCLUDED.title, ''), library.title),
|
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),
|
source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), library.source_url),
|
||||||
publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date),
|
publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date),
|
||||||
description = COALESCE(NULLIF(EXCLUDED.description, ''), library.description),
|
description = COALESCE(NULLIF(EXCLUDED.description, ''), library.description),
|
||||||
|
rating = CASE WHEN EXCLUDED.rating > 0 THEN EXCLUDED.rating ELSE library.rating END,
|
||||||
updated_at = NOW()
|
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("publish_date") or None,
|
||||||
meta.get("description", ""),
|
meta.get("description", ""),
|
||||||
bool(meta.get("needs_review", False)),
|
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,
|
rp.progress, rp.cfi, rp.page,
|
||||||
COALESCE(rs.read_count, 0)::int AS read_count,
|
COALESCE(rs.read_count, 0)::int AS read_count,
|
||||||
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
|
||||||
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 (
|
||||||
@ -392,29 +420,12 @@ def list_library_json() -> list[dict]:
|
|||||||
"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": tag_map.get(r[0], []),
|
||||||
|
"rating": r[19] or 0,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
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:
|
def normalize_site(raw: str) -> str:
|
||||||
raw = (raw or "").strip()
|
raw = (raw or "").strip()
|
||||||
if "://" in raw:
|
if "://" in raw:
|
||||||
|
|||||||
@ -18,7 +18,6 @@ from epub import detect_image_format, make_chapter_xhtml, make_epub
|
|||||||
from routers.common import (
|
from routers.common import (
|
||||||
LIBRARY_DIR,
|
LIBRARY_DIR,
|
||||||
ensure_cover_cache_for_book,
|
ensure_cover_cache_for_book,
|
||||||
ensure_cover_missing_tag,
|
|
||||||
ensure_unique_rel_path,
|
ensure_unique_rel_path,
|
||||||
make_rel_path,
|
make_rel_path,
|
||||||
normalize_site,
|
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", []))
|
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")
|
||||||
if cover_data is None and "Cover Missing" not in tags:
|
|
||||||
tags.append("Cover Missing")
|
|
||||||
|
|
||||||
status_map = {"Long-Term Hold": "Hiatus"}
|
status_map = {"Long-Term Hold": "Hiatus"}
|
||||||
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", ""))
|
||||||
|
|
||||||
@ -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 get_db_conn() as conn:
|
||||||
with conn:
|
with conn:
|
||||||
upsert_book(conn, rel_filename, book_meta, book_tags)
|
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")
|
ensure_cover_cache_for_book(conn, rel_filename, out_path, "epub")
|
||||||
|
|
||||||
send("done", {"filename": rel_filename, "title": book_title, "chapters": len(chapters)})
|
send("done", {"filename": rel_filename, "title": book_title, "chapters": len(chapters)})
|
||||||
|
|||||||
@ -13,7 +13,6 @@ from epub import add_cover_to_epub
|
|||||||
from routers.common import (
|
from routers.common import (
|
||||||
LIBRARY_DIR,
|
LIBRARY_DIR,
|
||||||
ensure_cover_cache_for_book,
|
ensure_cover_cache_for_book,
|
||||||
ensure_cover_missing_tag,
|
|
||||||
ensure_unique_rel_path,
|
ensure_unique_rel_path,
|
||||||
list_library_json,
|
list_library_json,
|
||||||
make_cover_thumb_webp,
|
make_cover_thumb_webp,
|
||||||
@ -50,7 +49,6 @@ def _sync_disk_to_db() -> int:
|
|||||||
continue
|
continue
|
||||||
tags = [(s, "subject") for s in meta.get("subjects", [])]
|
tags = [(s, "subject") for s in meta.get("subjects", [])]
|
||||||
upsert_book(conn, rel, meta, tags)
|
upsert_book(conn, rel, meta, tags)
|
||||||
ensure_cover_missing_tag(conn, rel, bool(meta.get("has_cover")))
|
|
||||||
if bool(meta.get("has_cover")):
|
if bool(meta.get("has_cover")):
|
||||||
ensure_cover_cache_for_book(conn, rel, p, meta["media_type"])
|
ensure_cover_cache_for_book(conn, rel, p, meta["media_type"])
|
||||||
synced += 1
|
synced += 1
|
||||||
@ -140,7 +138,6 @@ async def library_import(files: list[UploadFile] = File(...)):
|
|||||||
meta["needs_review"] = True
|
meta["needs_review"] = True
|
||||||
tags = [(s, "subject") for s in meta.get("subjects", [])]
|
tags = [(s, "subject") for s in meta.get("subjects", [])]
|
||||||
upsert_book(conn, rel_name, meta, tags)
|
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)
|
ensure_cover_cache_for_book(conn, rel_name, dest, media_type)
|
||||||
imported.append(rel_name)
|
imported.append(rel_name)
|
||||||
except Exception as e:
|
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)
|
upsert_cover_cache(conn, filename, "image/webp", thumb)
|
||||||
except (UnidentifiedImageError, OSError, ValueError):
|
except (UnidentifiedImageError, OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
ensure_cover_missing_tag(conn, filename, True)
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@ -368,7 +364,7 @@ async def api_home():
|
|||||||
l.series, l.series_index, l.publication_status,
|
l.series, l.series_index, l.publication_status,
|
||||||
l.media_type,
|
l.media_type,
|
||||||
COALESCE(rp.progress, 0) AS progress,
|
COALESCE(rp.progress, 0) AS progress,
|
||||||
rp.cfi
|
rp.cfi, l.rating
|
||||||
FROM reading_progress rp
|
FROM reading_progress rp
|
||||||
JOIN library l ON l.filename = rp.filename
|
JOIN library l ON l.filename = rp.filename
|
||||||
WHERE rp.progress > 0
|
WHERE rp.progress > 0
|
||||||
@ -380,7 +376,7 @@ async def api_home():
|
|||||||
|
|
||||||
cur.execute(
|
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
|
FROM library l
|
||||||
LEFT JOIN reading_sessions rs ON rs.filename = l.filename
|
LEFT JOIN reading_sessions rs ON rs.filename = l.filename
|
||||||
LEFT JOIN reading_progress rp ON rp.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 = 'Shorts'
|
||||||
AND bt.tag_type IN ('tag', 'subject')
|
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()
|
ORDER BY RANDOM()
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@ -404,7 +400,7 @@ async def api_home():
|
|||||||
|
|
||||||
cur.execute(
|
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
|
FROM library l
|
||||||
LEFT JOIN reading_sessions rs ON rs.filename = l.filename
|
LEFT JOIN reading_sessions rs ON rs.filename = l.filename
|
||||||
LEFT JOIN reading_progress rp ON rp.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 = 'Shorts'
|
||||||
AND bt.tag_type IN ('tag', 'subject')
|
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()
|
ORDER BY RANDOM()
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@ -429,7 +425,7 @@ async def api_home():
|
|||||||
cur.execute(
|
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,
|
||||||
MAX(rs.read_at) AS last_read
|
MAX(rs.read_at) AS last_read, l.rating
|
||||||
FROM library l
|
FROM library l
|
||||||
JOIN reading_sessions rs ON rs.filename = l.filename
|
JOIN reading_sessions rs ON rs.filename = l.filename
|
||||||
WHERE COALESCE(l.series, '') = ''
|
WHERE COALESCE(l.series, '') = ''
|
||||||
@ -442,7 +438,7 @@ async def api_home():
|
|||||||
AND bt.tag = 'Shorts'
|
AND bt.tag = 'Shorts'
|
||||||
AND bt.tag_type IN ('tag', 'subject')
|
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
|
ORDER BY MAX(rs.read_at) ASC
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@ -451,7 +447,7 @@ async def api_home():
|
|||||||
cur.execute(
|
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,
|
||||||
MAX(rs.read_at) AS last_read
|
MAX(rs.read_at) AS last_read, l.rating
|
||||||
FROM library l
|
FROM library l
|
||||||
JOIN reading_sessions rs ON rs.filename = l.filename
|
JOIN reading_sessions rs ON rs.filename = l.filename
|
||||||
WHERE COALESCE(l.series, '') = ''
|
WHERE COALESCE(l.series, '') = ''
|
||||||
@ -464,7 +460,7 @@ async def api_home():
|
|||||||
AND bt.tag = 'Shorts'
|
AND bt.tag = 'Shorts'
|
||||||
AND bt.tag_type IN ('tag', 'subject')
|
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
|
ORDER BY MAX(rs.read_at) ASC
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@ -479,6 +475,7 @@ async def api_home():
|
|||||||
"has_cover": bool(r[3]),
|
"has_cover": bool(r[3]),
|
||||||
"publication_status": r[4] or "",
|
"publication_status": r[4] or "",
|
||||||
"media_type": r[5] or "epub",
|
"media_type": r[5] or "epub",
|
||||||
|
"rating": r[6] or 0,
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
"series": "",
|
"series": "",
|
||||||
"series_index": 0,
|
"series_index": 0,
|
||||||
@ -496,6 +493,7 @@ async def api_home():
|
|||||||
"publication_status": r[4] or "",
|
"publication_status": r[4] or "",
|
||||||
"media_type": r[5] or "epub",
|
"media_type": r[5] or "epub",
|
||||||
"last_read": r[6].isoformat() if r[6] else None,
|
"last_read": r[6].isoformat() if r[6] else None,
|
||||||
|
"rating": r[7] or 0,
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
"series": "",
|
"series": "",
|
||||||
"series_index": 0,
|
"series_index": 0,
|
||||||
@ -516,6 +514,7 @@ async def api_home():
|
|||||||
"media_type": r[7] or "epub",
|
"media_type": r[7] or "epub",
|
||||||
"progress": r[8] or 0,
|
"progress": r[8] or 0,
|
||||||
"progress_cfi": r[9],
|
"progress_cfi": r[9],
|
||||||
|
"rating": r[10] or 0,
|
||||||
}
|
}
|
||||||
for r in cr_rows
|
for r in cr_rows
|
||||||
],
|
],
|
||||||
|
|||||||
@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from cbr import cbr_get_page
|
from cbr import cbr_get_page
|
||||||
from db import get_db_conn
|
from db import get_db_conn
|
||||||
from epub import read_epub_file, write_epub_file
|
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
|
from routers.common import LIBRARY_DIR, prune_empty_dirs, resolve_library_path, scan_epub
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -183,6 +183,78 @@ def _tag_local(name: str | None) -> str:
|
|||||||
return name.split(':', 1)[-1].lower()
|
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"<NovelaRating>\d+</NovelaRating>\s*", "", xml)
|
||||||
|
if rating > 0:
|
||||||
|
xml = xml.replace("</ComicInfo>", f" <NovelaRating>{rating}</NovelaRating>\n</ComicInfo>")
|
||||||
|
else:
|
||||||
|
ci_key = "ComicInfo.xml"
|
||||||
|
if rating > 0:
|
||||||
|
xml = f'<?xml version="1.0"?>\n<ComicInfo>\n <NovelaRating>{rating}</NovelaRating>\n</ComicInfo>\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(
|
def _sync_epub_metadata(
|
||||||
epub_path: Path,
|
epub_path: Path,
|
||||||
*,
|
*,
|
||||||
@ -331,20 +403,28 @@ def _make_rel_path(
|
|||||||
title: str,
|
title: str,
|
||||||
series: str,
|
series: str,
|
||||||
series_index: int | str | None,
|
series_index: int | str | None,
|
||||||
|
ext: str = ".epub",
|
||||||
) -> Path:
|
) -> Path:
|
||||||
pub_dir = _clean_segment(publisher, "Unknown Publisher", 80)
|
auth = _clean_segment(author, "Unknown Author", 80)
|
||||||
author_dir = _clean_segment(author, "Unknown Author", 80)
|
ttl = _clean_segment(title, "Untitled", 140)
|
||||||
clean_title = _clean_segment(title, "Untitled", 140)
|
|
||||||
clean_series = _clean_segment(series, "", 120)
|
if ext == ".epub":
|
||||||
if clean_series:
|
pub = _clean_segment(publisher, "Unknown Publisher", 80)
|
||||||
|
series_name = _clean_segment(series, "", 120)
|
||||||
|
if series_name:
|
||||||
idx = _coerce_series_index(series_index)
|
idx = _coerce_series_index(series_index)
|
||||||
filename = f"{idx:03d} - {clean_title}.epub"
|
return Path("epub") / pub / auth / "Series" / series_name / f"{idx:03d} - {ttl}.epub"
|
||||||
return Path(pub_dir) / author_dir / "Series" / clean_series / filename
|
return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub"
|
||||||
return Path(pub_dir) / author_dir / "Stories" / f"{clean_title}.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:
|
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
|
candidate = base
|
||||||
counter = 2
|
counter = 2
|
||||||
while True:
|
while True:
|
||||||
@ -543,7 +623,8 @@ async def book_detail_page(filename: str, request: Request):
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT title, author, publisher, has_cover, series, series_index,
|
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
|
FROM library WHERE filename = %s
|
||||||
""",
|
""",
|
||||||
(filename,),
|
(filename,),
|
||||||
@ -563,6 +644,7 @@ async def book_detail_page(filename: str, request: Request):
|
|||||||
"archived": lib_row[9] or False,
|
"archived": lib_row[9] or False,
|
||||||
"publish_date": lib_row[10].isoformat() if lib_row[10] else "",
|
"publish_date": lib_row[10].isoformat() if lib_row[10] else "",
|
||||||
"description": lib_row[11] or "",
|
"description": lib_row[11] or "",
|
||||||
|
"rating": lib_row[12] or 0,
|
||||||
}
|
}
|
||||||
# Supplement empty fields from EPUB metadata
|
# Supplement empty fields from EPUB metadata
|
||||||
if not entry["source_url"] or not entry["publish_date"] or not entry["description"]:
|
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("archived", False)
|
||||||
entry.setdefault("publish_date", "")
|
entry.setdefault("publish_date", "")
|
||||||
entry.setdefault("description", "")
|
entry.setdefault("description", "")
|
||||||
|
entry.setdefault("rating", 0)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT tag, tag_type FROM book_tags WHERE filename = %s ORDER BY tag_type, tag",
|
"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,
|
"last_read": last_read,
|
||||||
"progress": progress,
|
"progress": progress,
|
||||||
"cfi": cfi,
|
"cfi": cfi,
|
||||||
|
"rating": entry.get("rating", 0),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@ -679,6 +763,7 @@ async def book_update(filename: str, request: Request):
|
|||||||
publisher = body.get("publisher", "")
|
publisher = body.get("publisher", "")
|
||||||
series = body.get("series", "")
|
series = body.get("series", "")
|
||||||
series_index = _coerce_series_index(body.get("series_index", 1))
|
series_index = _coerce_series_index(body.get("series_index", 1))
|
||||||
|
ext = old_path.suffix.lower()
|
||||||
|
|
||||||
target_rel = _make_rel_path(
|
target_rel = _make_rel_path(
|
||||||
publisher=publisher,
|
publisher=publisher,
|
||||||
@ -686,20 +771,20 @@ async def book_update(filename: str, request: Request):
|
|||||||
title=title,
|
title=title,
|
||||||
series=series,
|
series=series,
|
||||||
series_index=series_index,
|
series_index=series_index,
|
||||||
|
ext=ext,
|
||||||
)
|
)
|
||||||
target_rel = _ensure_unique_rel_path(target_rel, exclude=old_path)
|
target_rel = _ensure_unique_rel_path(target_rel, exclude=old_path)
|
||||||
new_filename = target_rel.as_posix()
|
new_filename = target_rel.as_posix()
|
||||||
new_path = (LIBRARY_DIR / target_rel).resolve()
|
new_path = (LIBRARY_DIR / target_rel).resolve()
|
||||||
|
|
||||||
moved = False
|
moved = False
|
||||||
old_parent_to_prune: Path | None = None
|
|
||||||
if new_path != old_path:
|
if new_path != old_path:
|
||||||
new_path.parent.mkdir(parents=True, exist_ok=True)
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
old_path.replace(new_path)
|
old_path.replace(new_path)
|
||||||
moved = True
|
moved = True
|
||||||
old_parent_to_prune = old_path.parent
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if ext == ".epub":
|
||||||
_sync_epub_metadata(
|
_sync_epub_metadata(
|
||||||
new_path,
|
new_path,
|
||||||
title=title,
|
title=title,
|
||||||
@ -778,8 +863,7 @@ async def book_update(filename: str, request: Request):
|
|||||||
rows,
|
rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
if old_parent_to_prune is not None:
|
prune_empty_dirs(old_path.parent)
|
||||||
prune_empty_dirs(old_parent_to_prune)
|
|
||||||
return JSONResponse({"ok": True, "filename": new_filename, "renamed": new_filename != filename})
|
return JSONResponse({"ok": True, "filename": new_filename, "renamed": new_filename != filename})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if moved and new_path.exists() and not old_path.exists():
|
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)
|
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)
|
@router.get("/library/read/{filename:path}", response_class=HTMLResponse)
|
||||||
async def reader_page(filename: str, request: Request):
|
async def reader_page(filename: str, request: Request):
|
||||||
path = resolve_library_path(filename)
|
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,))
|
cur.execute("SELECT title FROM library WHERE filename = %s", (filename,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
title = row[0] if row and row[0] else filename
|
title = row[0] if row and row[0] else filename
|
||||||
|
fmt = path.suffix.lower().lstrip(".")
|
||||||
return templates.TemplateResponse(request, "reader.html", {
|
return templates.TemplateResponse(request, "reader.html", {
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"title": title,
|
"title": title,
|
||||||
|
"format": fmt,
|
||||||
"epub_url": f"/library/epub/{filename}",
|
"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}")
|
@router.get("/library/pdf/{filename:path}")
|
||||||
async def library_pdf_page(filename: str, page: int = 0, dpi: int = 150):
|
async def library_pdf_page(filename: str, page: int = 0, dpi: int = 150):
|
||||||
path = resolve_library_path(filename)
|
path = resolve_library_path(filename)
|
||||||
|
|||||||
@ -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 img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
.cover-wrap canvas { width: 100%; height: 100%; display: block; }
|
.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 */
|
/* Want to Read star under cover */
|
||||||
.btn-wtr {
|
.btn-wtr {
|
||||||
display: flex; align-items: center; gap: 0.5rem;
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
|||||||
@ -52,6 +52,27 @@ if (BOOK.has_cover) {
|
|||||||
else if (img) img.onload = () => canvas.style.display = 'none';
|
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 ────────────────────────────────────────────────────
|
// ── Want to Read toggle ────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function toggleWtr() {
|
async function toggleWtr() {
|
||||||
@ -187,6 +208,11 @@ class PillInput {
|
|||||||
this._hideDropdown();
|
this._hideDropdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
const v = this.input.value.trim();
|
||||||
|
if (v) this._add(v);
|
||||||
|
}
|
||||||
|
|
||||||
_showDropdown(items) {
|
_showDropdown(items) {
|
||||||
if (!items.length) { this.dropdown.style.display = 'none'; return; }
|
if (!items.length) { this.dropdown.style.display = 'none'; return; }
|
||||||
this.dropdown.innerHTML = items.map(g =>
|
this.dropdown.innerHTML = items.map(g =>
|
||||||
@ -221,7 +247,7 @@ class PillInput {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.ddIndex = Math.max(this.ddIndex - 1, -1);
|
this.ddIndex = Math.max(this.ddIndex - 1, -1);
|
||||||
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
|
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();
|
e.preventDefault();
|
||||||
if (this.ddIndex >= 0 && opts[this.ddIndex]) this._add(opts[this.ddIndex].dataset.val);
|
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);
|
else if (this.input.value.trim()) this._add(this.input.value);
|
||||||
@ -274,6 +300,9 @@ function closeEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
|
genreInput.flush();
|
||||||
|
subgenreInput.flush();
|
||||||
|
tagInput.flush();
|
||||||
const body = {
|
const body = {
|
||||||
title: document.getElementById('ed-title').value,
|
title: document.getElementById('ed-title').value,
|
||||||
author: document.getElementById('ed-author').value,
|
author: document.getElementById('ed-author').value,
|
||||||
|
|||||||
@ -455,6 +455,36 @@ html, body {
|
|||||||
padding-top: 0.8rem;
|
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 view controls + list mode ─────────────────────────────────────── */
|
||||||
|
|
||||||
.new-controls {
|
.new-controls {
|
||||||
|
|||||||
@ -12,6 +12,8 @@ const MISSING_PUBLISHER_LABEL = 'No publisher';
|
|||||||
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
|
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
|
||||||
const NEW_VIEW_MODE_KEY = 'novela.new.viewMode';
|
const NEW_VIEW_MODE_KEY = 'novela.new.viewMode';
|
||||||
const NEW_VISIBLE_COLUMNS_KEY = 'novela.new.visibleColumns';
|
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_DEFAULT_COLUMNS = ['publisher', 'author', 'series', 'volume', 'title', 'has_cover', 'updated', 'genres', 'subgenres', 'tags', 'status'];
|
||||||
const NEW_COLUMN_DEFS = [
|
const NEW_COLUMN_DEFS = [
|
||||||
{ id: 'publisher', label: 'Publisher' },
|
{ id: 'publisher', label: 'Publisher' },
|
||||||
@ -25,12 +27,15 @@ const NEW_COLUMN_DEFS = [
|
|||||||
{ id: 'subgenres', label: 'Sub-genres' },
|
{ id: 'subgenres', label: 'Sub-genres' },
|
||||||
{ id: 'tags', label: 'Tags' },
|
{ id: 'tags', label: 'Tags' },
|
||||||
{ id: 'status', label: 'Status' },
|
{ id: 'status', label: 'Status' },
|
||||||
|
{ id: 'rating', label: 'Rating' },
|
||||||
];
|
];
|
||||||
|
|
||||||
let newViewMode = loadNewViewMode();
|
let newViewMode = loadNewViewMode();
|
||||||
let newVisibleColumns = loadNewVisibleColumns();
|
let newVisibleColumns = loadNewVisibleColumns();
|
||||||
let newSelectedFilenames = new Set();
|
let newSelectedFilenames = new Set();
|
||||||
let newLastToggledIndex = null;
|
let newLastToggledIndex = null;
|
||||||
|
let allViewMode = loadAllViewMode();
|
||||||
|
let allVisibleColumns = loadAllVisibleColumns();
|
||||||
|
|
||||||
|
|
||||||
// ── Placeholder cover generation ───────────────────────────────────────────
|
// ── Placeholder cover generation ───────────────────────────────────────────
|
||||||
@ -244,7 +249,8 @@ window.addEventListener('popstate', e => {
|
|||||||
function renderGrid() {
|
function renderGrid() {
|
||||||
const active = activeBooks();
|
const active = activeBooks();
|
||||||
if (currentView !== 'new') hideNewControls();
|
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 === 'wtr') renderBooksGrid(active.filter(b => b.want_to_read));
|
||||||
else if (currentView === 'series') renderSeriesGrid();
|
else if (currentView === 'series') renderSeriesGrid();
|
||||||
else if (currentView === 'series-detail') renderSeriesDetail(currentParam);
|
else if (currentView === 'series-detail') renderSeriesDetail(currentParam);
|
||||||
@ -454,6 +460,44 @@ function formatUpdated(iso) {
|
|||||||
return `${y}-${m}-${day}`;
|
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 = `<div class="${cls}" id="stars-${id}">`;
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const onclick = interactive ? ` onclick="event.stopPropagation();rateBook('${jsEsc(filename)}',${i})"` : '';
|
||||||
|
html += `<span class="star ${i <= r ? 'filled' : ''}"${onclick}>★</span>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
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) {
|
function newCellText(book, colId) {
|
||||||
if (colId === 'publisher') return publisherDisplayName(bookPublisherKey(book));
|
if (colId === 'publisher') return publisherDisplayName(bookPublisherKey(book));
|
||||||
if (colId === 'author') return bookAuthor(book);
|
if (colId === 'author') return bookAuthor(book);
|
||||||
@ -466,6 +510,7 @@ function newCellText(book, colId) {
|
|||||||
if (colId === 'tags') return bookPlainTags(book).join(', ');
|
if (colId === 'tags') return bookPlainTags(book).join(', ');
|
||||||
if (colId === 'volume') return book.series_index > 0 ? String(book.series_index) : '';
|
if (colId === 'volume') return book.series_index > 0 ? String(book.series_index) : '';
|
||||||
if (colId === 'status') return book.publication_status || '';
|
if (colId === 'status') return book.publication_status || '';
|
||||||
|
if (colId === 'rating') return starsText(book.rating);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -573,6 +618,146 @@ function renderNewBooksView(books) {
|
|||||||
renderBooksGrid(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 = `
|
||||||
|
<div class="new-controls-bar">
|
||||||
|
<div class="new-view-toggle">
|
||||||
|
<button class="btn btn-view ${allViewMode === 'grid' ? 'active' : ''}" onclick="setAllViewMode('grid')">Grid</button>
|
||||||
|
<button class="btn btn-view ${allViewMode === 'list' ? 'active' : ''}" onclick="setAllViewMode('list')">List</button>
|
||||||
|
</div>
|
||||||
|
${allViewMode === 'list' ? `
|
||||||
|
<div class="new-actions">
|
||||||
|
<button class="btn btn-light" onclick="toggleAllColumnsMenu(event)">Columns</button>
|
||||||
|
<div class="new-columns-menu" id="all-columns-menu">
|
||||||
|
${NEW_COLUMN_DEFS.map(col => `
|
||||||
|
<label class="new-col-item">
|
||||||
|
<input type="checkbox" ${allVisibleColumns.includes(col.id) ? 'checked' : ''} onchange="toggleAllColumn('${col.id}')"/>
|
||||||
|
<span>${esc(col.label)}</span>
|
||||||
|
</label>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAllBooksList(books) {
|
||||||
|
const container = document.getElementById('grid-container');
|
||||||
|
if (!books.length) {
|
||||||
|
container.innerHTML = '<div class="empty">No books yet. Import EPUB, PDF or CBR/CBZ to get started.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cols = NEW_COLUMN_DEFS.filter(c => allVisibleColumns.includes(c.id));
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="new-list-wrap">
|
||||||
|
<table class="new-list-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
${cols.map(c => `<th>${esc(c.label)}</th>`).join('')}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${books.map(b => `
|
||||||
|
<tr class="all-list-row" data-filename="${esc(b.filename)}">
|
||||||
|
${cols.map(c => {
|
||||||
|
const value = newCellText(b, c.id);
|
||||||
|
if (c.id === 'title') return `<td class="col-title">${esc(value)}</td>`;
|
||||||
|
if (c.id === 'has_cover') return `<td class="col-center">${esc(value)}</td>`;
|
||||||
|
return `<td>${esc(value)}</td>`;
|
||||||
|
}).join('')}
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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) ─────────────────────────────────
|
// ── Book grid (All / WTR / Author detail) ─────────────────────────────────
|
||||||
|
|
||||||
function renderBooksGrid(books) {
|
function renderBooksGrid(books) {
|
||||||
@ -635,6 +820,7 @@ function renderBooksGrid(books) {
|
|||||||
${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>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${starsHtml(b.filename, b.rating)}
|
||||||
<div class="book-info">
|
<div class="book-info">
|
||||||
<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>
|
||||||
@ -864,6 +1050,7 @@ function renderSeriesDetail(seriesName) {
|
|||||||
${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>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${starsHtml(b.filename, b.rating)}
|
||||||
<div class="book-info">
|
<div class="book-info">
|
||||||
<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>
|
||||||
@ -1021,7 +1208,11 @@ function renderPublishersView() {
|
|||||||
// ── Genre view ─────────────────────────────────────────────────────────────
|
// ── Genre view ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function renderGenreView(tag) {
|
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);
|
renderBooksGrid(books);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1033,7 +1224,9 @@ function renderSearchResults(query) {
|
|||||||
const books = activeBooks().filter(b =>
|
const books = activeBooks().filter(b =>
|
||||||
bookTitle(b).toLowerCase().includes(q) ||
|
bookTitle(b).toLowerCase().includes(q) ||
|
||||||
bookAuthor(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);
|
renderBooksGrid(books);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set r = (rating | default(0)) | int %}
|
||||||
|
<div class="star-row interactive" id="book-stars">
|
||||||
|
{% for i in range(1, 6) %}
|
||||||
|
<span class="star {% if i <= r %}filled{% endif %}" onclick="rateBook({{ i }})">★</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn-wtr {% if want_to_read %}active{% endif %}" id="wtr-btn" onclick="toggleWtr()">
|
<button class="btn-wtr {% if want_to_read %}active{% endif %}" id="wtr-btn" onclick="toggleWtr()">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="{% if want_to_read %}currentColor{% else %}none{% endif %}" stroke="currentColor" stroke-width="2.5" id="wtr-svg">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="{% if want_to_read %}currentColor{% else %}none{% endif %}" stroke="currentColor" stroke-width="2.5" id="wtr-svg">
|
||||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||||
@ -172,6 +179,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
{% if filename.endswith('.epub') %}
|
||||||
<a class="btn-secondary" href="/library/editor/{{ filename | urlencode }}">
|
<a class="btn-secondary" href="/library/editor/{{ filename | urlencode }}">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
<polyline points="16 18 22 12 16 6"/>
|
<polyline points="16 18 22 12 16 6"/>
|
||||||
@ -179,6 +187,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Edit EPUB
|
Edit EPUB
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
<input type="file" id="cover-input" accept="image/*" style="display:none" onchange="uploadCover(this)"/>
|
<input type="file" id="cover-input" accept="image/*" style="display:none" onchange="uploadCover(this)"/>
|
||||||
<button class="btn-secondary" onclick="document.getElementById('cover-input').click()">
|
<button class="btn-secondary" onclick="document.getElementById('cover-input').click()">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
@ -312,6 +321,7 @@
|
|||||||
subgenres: {{ subgenres | tojson }},
|
subgenres: {{ subgenres | tojson }},
|
||||||
tags: {{ tags | tojson }},
|
tags: {{ tags | tojson }},
|
||||||
has_cover: {{ 'true' if has_cover else 'false' }},
|
has_cover: {{ 'true' if has_cover else 'false' }},
|
||||||
|
rating: {{ rating or 0 }},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/book.js"></script>
|
<script src="/static/book.js"></script>
|
||||||
|
|||||||
@ -175,6 +175,11 @@
|
|||||||
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
||||||
margin-top: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
margin-top: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.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); }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
text-align: center; color: var(--text-faint); font-family: var(--mono);
|
text-align: center; color: var(--text-faint); font-family: var(--mono);
|
||||||
@ -329,7 +334,37 @@
|
|||||||
|
|
||||||
function trunc(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
function trunc(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
||||||
function esc(s) { return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function esc(s) { return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
function jsEsc(s) { return String(s || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
|
||||||
function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); }
|
function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); }
|
||||||
|
|
||||||
|
function starsHtml(filename, rating) {
|
||||||
|
const r = rating || 0;
|
||||||
|
const id = cssId(filename);
|
||||||
|
let html = `<div class="star-row" id="hstars-${id}">`;
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
html += `<span class="star ${i <= r ? 'filled' : ''}">★</span>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
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(`hstars-${id}`);
|
||||||
|
if (row) row.outerHTML = starsHtml(filename, result.rating);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
function filenameBase(filename) {
|
function filenameBase(filename) {
|
||||||
const leaf = String(filename || '').split('/').pop() || '';
|
const leaf = String(filename || '').split('/').pop() || '';
|
||||||
return leaf.replace(/\.[^.]+$/, '');
|
return leaf.replace(/\.[^.]+$/, '');
|
||||||
@ -368,6 +403,7 @@
|
|||||||
<div class="h-cover" id="hc-${id}">
|
<div class="h-cover" id="hc-${id}">
|
||||||
<canvas id="hcv-${id}" style="width:100%;height:100%;display:block"></canvas>
|
<canvas id="hcv-${id}" style="width:100%;height:100%;display:block"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
${starsHtml(b.filename, b.rating)}
|
||||||
<div class="h-info">
|
<div class="h-info">
|
||||||
<div class="h-title">${esc(title)}</div>
|
<div class="h-title">${esc(title)}</div>
|
||||||
${showProgress
|
${showProgress
|
||||||
@ -393,6 +429,7 @@
|
|||||||
: ''}
|
: ''}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
${starsHtml(b.filename, b.rating)}
|
||||||
<div class="book-info">
|
<div class="book-info">
|
||||||
<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>
|
||||||
|
|||||||
@ -36,6 +36,7 @@
|
|||||||
<div class="import-title">Drop EPUB, PDF or CBR/CBZ files here</div>
|
<div class="import-title">Drop EPUB, PDF or CBR/CBZ files here</div>
|
||||||
<div class="import-sub">or click to choose files</div>
|
<div class="import-sub">or click to choose files</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="all-controls" class="new-controls" style="display:none"></div>
|
||||||
<div id="new-controls" class="new-controls" style="display:none"></div>
|
<div id="new-controls" class="new-controls" style="display:none"></div>
|
||||||
<div id="grid-container">
|
<div id="grid-container">
|
||||||
<div class="empty">Loading…</div>
|
<div class="empty">Loading…</div>
|
||||||
|
|||||||
@ -42,6 +42,7 @@
|
|||||||
color: var(--text-dim); text-decoration: none;
|
color: var(--text-dim); text-decoration: none;
|
||||||
display: flex; align-items: center; gap: 0.35rem;
|
display: flex; align-items: center; gap: 0.35rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-left: 1rem;
|
||||||
transition: color 0.12s;
|
transition: color 0.12s;
|
||||||
}
|
}
|
||||||
.header-back:hover { color: var(--text); }
|
.header-back:hover { color: var(--text); }
|
||||||
@ -107,6 +108,18 @@
|
|||||||
background: transparent; cursor: pointer;
|
background: transparent; cursor: pointer;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
}
|
}
|
||||||
|
.colour-swatches {
|
||||||
|
display: flex; gap: 0.55rem; align-items: center; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.colour-swatch {
|
||||||
|
width: 24px; height: 24px; border-radius: 50%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer; transition: border-color 0.12s, transform 0.1s;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.colour-swatch:hover { transform: scale(1.15); }
|
||||||
|
.colour-swatch.active { border-color: var(--accent); }
|
||||||
|
|
||||||
/* ── Viewer ── */
|
/* ── Viewer ── */
|
||||||
#viewer {
|
#viewer {
|
||||||
@ -231,6 +244,16 @@
|
|||||||
<input type="range" id="width-slider" min="30" max="100" step="1"
|
<input type="range" id="width-slider" min="30" max="100" step="1"
|
||||||
value="65" oninput="applyWidth(this.value)"/>
|
value="65" oninput="applyWidth(this.value)"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">Text colour</div>
|
||||||
|
<div class="colour-swatches">
|
||||||
|
<button class="colour-swatch" data-colour="#e8e2d9" title="Bright" style="background:#e8e2d9" onclick="applyTextColour('#e8e2d9')"></button>
|
||||||
|
<button class="colour-swatch" data-colour="#d4cec5" title="Warm cream" style="background:#d4cec5" onclick="applyTextColour('#d4cec5')"></button>
|
||||||
|
<button class="colour-swatch" data-colour="#bfb8ae" title="Soft sand" style="background:#bfb8ae" onclick="applyTextColour('#bfb8ae')"></button>
|
||||||
|
<button class="colour-swatch" data-colour="#a9a29a" title="Muted" style="background:#a9a29a" onclick="applyTextColour('#a9a29a')"></button>
|
||||||
|
<button class="colour-swatch" data-colour="#938d86" title="Dim" style="background:#938d86" onclick="applyTextColour('#938d86')"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@ -289,6 +312,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const filename = {{ filename | tojson }};
|
const filename = {{ filename | tojson }};
|
||||||
|
const FORMAT = {{ format | tojson }};
|
||||||
|
|
||||||
let chapters = [];
|
let chapters = [];
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
@ -309,6 +333,20 @@
|
|||||||
applyWidth(saved);
|
applyWidth(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Text colour ────────────────────────────────────────────────
|
||||||
|
function applyTextColour(hex) {
|
||||||
|
document.documentElement.style.setProperty('--text', hex);
|
||||||
|
localStorage.setItem('reader-text-colour', hex);
|
||||||
|
document.querySelectorAll('.colour-swatch').forEach(el => {
|
||||||
|
el.classList.toggle('active', el.dataset.colour === hex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTextColour() {
|
||||||
|
const saved = localStorage.getItem('reader-text-colour') || '#e8e2d9';
|
||||||
|
applyTextColour(saved);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Settings drawer ────────────────────────────────────────────
|
// ── Settings drawer ────────────────────────────────────────────
|
||||||
function toggleSettings() {
|
function toggleSettings() {
|
||||||
const open = document.getElementById('settings-drawer').classList.toggle('open');
|
const open = document.getElementById('settings-drawer').classList.toggle('open');
|
||||||
@ -320,20 +358,18 @@
|
|||||||
document.getElementById('settings-overlay').classList.remove('open');
|
document.getElementById('settings-overlay').classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Progress (chapter + scroll within chapter) ─────────────────
|
// ── Progress ───────────────────────────────────────────────────
|
||||||
function calcProgress() {
|
function calcProgress() {
|
||||||
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
|
|
||||||
const scrollFrac = maxScroll > 0
|
|
||||||
? Math.min(1, window.scrollY / maxScroll)
|
|
||||||
: 0;
|
|
||||||
// Multi-chapter: (chapterIndex + scrollFrac) / (total - 1) × 100
|
|
||||||
// Single-chapter: use scroll position only so it doesn't start at 100%.
|
|
||||||
const total = chapters.length;
|
const total = chapters.length;
|
||||||
|
if (FORMAT === 'pdf') {
|
||||||
|
const pct = total > 1 ? Math.round((currentIndex / (total - 1)) * 100) : 0;
|
||||||
|
return { scrollFrac: 0, pct };
|
||||||
|
}
|
||||||
|
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
const scrollFrac = maxScroll > 0 ? Math.min(1, window.scrollY / maxScroll) : 0;
|
||||||
const pct = total > 1
|
const pct = total > 1
|
||||||
? Math.round(((currentIndex + scrollFrac) / (total - 1)) * 100)
|
? Math.round(((currentIndex + scrollFrac) / (total - 1)) * 100)
|
||||||
: total === 1
|
: total === 1 ? Math.round(scrollFrac * 100) : 0;
|
||||||
? Math.round(scrollFrac * 100)
|
|
||||||
: 0;
|
|
||||||
return { scrollFrac, pct };
|
return { scrollFrac, pct };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -358,7 +394,31 @@
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chapter loading ────────────────────────────────────────────
|
// ── PDF page loading ───────────────────────────────────────────
|
||||||
|
async function loadPdfPage(index, saveProgress) {
|
||||||
|
if (index < 0 || index >= chapters.length) return;
|
||||||
|
currentIndex = index;
|
||||||
|
|
||||||
|
const content = document.getElementById('chapter-content');
|
||||||
|
content.innerHTML =
|
||||||
|
`<div style="text-align:center">` +
|
||||||
|
`<img src="/library/pdf/${encodeURIComponent(filename)}?page=${index}&dpi=150"` +
|
||||||
|
` style="max-width:100%;height:auto;border-radius:4px" alt="Page ${index + 1}"/>` +
|
||||||
|
`</div>`;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
|
document.getElementById('header-title').innerHTML =
|
||||||
|
`<strong>Page ${index + 1} / ${chapters.length}</strong>`;
|
||||||
|
document.getElementById('btn-prev').disabled = index === 0;
|
||||||
|
document.getElementById('btn-next').disabled = index === chapters.length - 1;
|
||||||
|
document.getElementById('chapter-nav-label').textContent =
|
||||||
|
`${index + 1} / ${chapters.length}`;
|
||||||
|
|
||||||
|
updateFooter();
|
||||||
|
if (saveProgress) scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EPUB chapter loading ───────────────────────────────────────
|
||||||
async function loadChapter(index, saveProgress, scrollFrac) {
|
async function loadChapter(index, saveProgress, scrollFrac) {
|
||||||
if (index < 0 || index >= chapters.length) return;
|
if (index < 0 || index >= chapters.length) return;
|
||||||
currentIndex = index;
|
currentIndex = index;
|
||||||
@ -367,7 +427,6 @@
|
|||||||
const html = await resp.text();
|
const html = await resp.text();
|
||||||
document.getElementById('chapter-content').innerHTML = html;
|
document.getElementById('chapter-content').innerHTML = html;
|
||||||
|
|
||||||
// Restore scroll position within chapter (after DOM paint)
|
|
||||||
if (scrollFrac && scrollFrac > 0) {
|
if (scrollFrac && scrollFrac > 0) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@ -379,31 +438,34 @@
|
|||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update header
|
|
||||||
const ch = chapters[index];
|
const ch = chapters[index];
|
||||||
document.getElementById('header-title').innerHTML =
|
document.getElementById('header-title').innerHTML =
|
||||||
ch ? `<strong>${esc(ch.title)}</strong>` : '';
|
ch ? `<strong>${esc(ch.title)}</strong>` : '';
|
||||||
|
|
||||||
// Update nav
|
|
||||||
document.getElementById('btn-prev').disabled = index === 0;
|
document.getElementById('btn-prev').disabled = index === 0;
|
||||||
document.getElementById('btn-next').disabled = index === chapters.length - 1;
|
document.getElementById('btn-next').disabled = index === chapters.length - 1;
|
||||||
document.getElementById('chapter-nav-label').textContent =
|
document.getElementById('chapter-nav-label').textContent =
|
||||||
`${index + 1} / ${chapters.length}`;
|
`${index + 1} / ${chapters.length}`;
|
||||||
|
|
||||||
updateFooter();
|
updateFooter();
|
||||||
|
|
||||||
if (saveProgress) scheduleSave();
|
if (saveProgress) scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigate(delta) {
|
function navigate(delta) {
|
||||||
|
if (FORMAT === 'pdf') {
|
||||||
|
loadPdfPage(currentIndex + delta, true);
|
||||||
|
} else {
|
||||||
loadChapter(currentIndex + delta, true, 0);
|
loadChapter(currentIndex + delta, true, 0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scroll tracking ────────────────────────────────────────────
|
// ── Scroll tracking (EPUB only) ────────────────────────────────
|
||||||
window.addEventListener('scroll', () => {
|
window.addEventListener('scroll', () => {
|
||||||
updateFooter();
|
updateFooter();
|
||||||
|
if (FORMAT !== 'pdf') {
|
||||||
clearTimeout(scrollTimer);
|
clearTimeout(scrollTimer);
|
||||||
scrollTimer = setTimeout(scheduleSave, 300);
|
scrollTimer = setTimeout(scheduleSave, 300);
|
||||||
|
}
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
// ── Keyboard navigation ────────────────────────────────────────
|
// ── Keyboard navigation ────────────────────────────────────────
|
||||||
@ -416,26 +478,36 @@
|
|||||||
// ── Init ───────────────────────────────────────────────────────
|
// ── Init ───────────────────────────────────────────────────────
|
||||||
async function init() {
|
async function init() {
|
||||||
loadWidth();
|
loadWidth();
|
||||||
|
loadTextColour();
|
||||||
|
|
||||||
const [r1, r2] = await Promise.all([
|
const progResp = await fetch(`/library/progress/${encodeURIComponent(filename)}`);
|
||||||
fetch(`/library/chapters/${encodeURIComponent(filename)}`),
|
const prog = await progResp.json();
|
||||||
fetch(`/library/progress/${encodeURIComponent(filename)}`),
|
|
||||||
]);
|
|
||||||
chapters = await r1.json();
|
|
||||||
const prog = await r2.json();
|
|
||||||
|
|
||||||
let startIndex = 0;
|
let startIndex = 0;
|
||||||
let startScroll = 0;
|
let startScroll = 0;
|
||||||
if (prog.cfi) {
|
if (prog.cfi) {
|
||||||
const parts = prog.cfi.split(':');
|
const parts = prog.cfi.split(':');
|
||||||
const idx = parseInt(parts[0], 10);
|
const idx = parseInt(parts[0], 10);
|
||||||
if (!isNaN(idx) && idx >= 0 && idx < chapters.length) {
|
if (!isNaN(idx) && idx >= 0) {
|
||||||
startIndex = idx;
|
startIndex = idx;
|
||||||
startScroll = parseFloat(parts[1]) || 0;
|
startScroll = parseFloat(parts[1]) || 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (FORMAT === 'pdf') {
|
||||||
|
const infoResp = await fetch(`/api/pdf/info/${encodeURIComponent(filename)}`);
|
||||||
|
const info = await infoResp.json();
|
||||||
|
const pageCount = info.page_count || 1;
|
||||||
|
chapters = Array.from({ length: pageCount }, (_, i) => ({ index: i, title: `Page ${i + 1}` }));
|
||||||
|
if (startIndex >= chapters.length) startIndex = 0;
|
||||||
|
await loadPdfPage(startIndex, false);
|
||||||
|
} else {
|
||||||
|
const chapResp = await fetch(`/library/chapters/${encodeURIComponent(filename)}`);
|
||||||
|
chapters = await chapResp.json();
|
||||||
|
if (startIndex >= chapters.length) startIndex = 0;
|
||||||
await loadChapter(startIndex, false, startScroll);
|
await loadChapter(startIndex, false, startScroll);
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('loading').style.display = 'none';
|
document.getElementById('loading').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ It is the primary technical reference for the current implementation.
|
|||||||
- `POST /library/want-to-read/{filename}`
|
- `POST /library/want-to-read/{filename}`
|
||||||
- `POST /library/archive/{filename}`
|
- `POST /library/archive/{filename}`
|
||||||
- `POST /library/new/mark-reviewed` (bulk `needs_review=false`)
|
- `POST /library/new/mark-reviewed` (bulk `needs_review=false`)
|
||||||
|
- `POST /library/rating/{filename}` (set/clear star rating, body: `{"rating": 0-5}`)
|
||||||
- `GET /home`
|
- `GET /home`
|
||||||
- `GET /api/home`
|
- `GET /api/home`
|
||||||
- `GET /stats`
|
- `GET /stats`
|
||||||
@ -63,10 +64,12 @@ Home read sections are ordered oldest-first:
|
|||||||
### `routers/reader.py`
|
### `routers/reader.py`
|
||||||
- EPUB serving/chapters/images
|
- EPUB serving/chapters/images
|
||||||
- Reader page + book detail
|
- Reader page + book detail
|
||||||
- Metadata patch (`PATCH /library/book/{filename}`)
|
- Metadata patch (`PATCH /library/book/{filename}`): updates DB for all formats; writes to file only for EPUB
|
||||||
- Progress read/write/delete
|
- Progress read/write/delete
|
||||||
- Mark-as-read
|
- Mark-as-read
|
||||||
- PDF render endpoint
|
- Star rating (`POST /library/rating/{filename}`): validates 0–5, writes to file (EPUB OPF / CBZ ComicInfo.xml) and DB; DB-only for CBR/PDF
|
||||||
|
- PDF render endpoint (`GET /library/pdf/{filename}?page=N&dpi=150`) — returns page as PNG
|
||||||
|
- PDF info endpoint (`GET /api/pdf/info/{filename}`) — returns `{"page_count": N}`
|
||||||
- CBR/CBZ page endpoint
|
- CBR/CBZ page endpoint
|
||||||
- Genres endpoint
|
- Genres endpoint
|
||||||
|
|
||||||
@ -142,18 +145,45 @@ Dropbox settings are managed via the web UI on `/backup`.
|
|||||||
- Status
|
- Status
|
||||||
- `List` mode supports multi-select with `Shift+click` range selection on checkboxes.
|
- `List` mode supports multi-select with `Shift+click` range selection on checkboxes.
|
||||||
- `Grid` mode shows no selection checkboxes or bulk actions.
|
- `Grid` mode shows no selection checkboxes or bulk actions.
|
||||||
|
- `All books` view supports `Grid` and `List` mode (same columns as `New`, no selection/bulk actions).
|
||||||
|
- View mode persisted in `localStorage` as `novela.all.viewMode`.
|
||||||
|
- Column visibility persisted in `localStorage` as `novela.all.visibleColumns`.
|
||||||
|
- Star ratings (1–5) are shown under the cover in all grid views (Library, Home):
|
||||||
|
- Display-only in grid cards (no click handler, prevents accidental taps).
|
||||||
|
- Interactive in Book Detail (1.1rem, clickable; clicking the active star clears the rating).
|
||||||
|
- Amber color: filled `#c8a03a`, unfilled `rgba(200, 160, 58, 0.25)`.
|
||||||
|
- Reader has a text colour setting in the hamburger menu:
|
||||||
|
- 5 presets from `#e8e2d9` (bright) to `#938d86` (dim), persisted in `localStorage` as `reader-text-colour`.
|
||||||
|
- Hamburger and back-link are visually separated with `margin-left: 1rem` on `.header-back`.
|
||||||
- Backup page supports:
|
- Backup page supports:
|
||||||
- manual run and dry-run
|
- manual run and dry-run
|
||||||
- Dropbox root settings
|
- Dropbox root settings
|
||||||
- snapshot retention count
|
- snapshot retention count
|
||||||
- scheduled backup (on/off + interval in hours)
|
- scheduled backup (on/off + interval in hours)
|
||||||
- status + history overview
|
- status + history overview
|
||||||
|
- Reader supports EPUB and PDF:
|
||||||
|
- EPUB: chapter-text rendering (existing flow)
|
||||||
|
- PDF: page-image rendering via `/library/pdf/{filename}?page=N`; page count fetched from `/api/pdf/info/{filename}`; progress tracked per page; keyboard/button navigation identical to EPUB
|
||||||
|
- `reader.html` branches on `FORMAT` variable injected by the server
|
||||||
|
- `Edit EPUB` button in Book Detail is only shown for `.epub` files.
|
||||||
|
|
||||||
|
## Known Bugs Fixed
|
||||||
|
- `renderGenreView` and `renderSearchResults` in `library.js` referenced `b.genres` (non-existent field on the book object). All tag data lives in `b.tags` as `{tag, tag_type}` objects; the correct helpers are `bookGenres()`, `bookSubgenres()`, `bookPlainTags()`.
|
||||||
|
- `PillInput` in `book.js` did not handle comma as a delimiter and did not flush pending input on save. Fixed with comma keydown handler and `flush()` called in `saveEdit()`.
|
||||||
|
- `PATCH /library/book/{filename}` failed for PDFs: `_sync_epub_metadata` tried to open the PDF as a ZIP, throwing an exception that aborted the entire save (including the DB update). Fixed by only calling `_sync_epub_metadata` when `ext == ".epub"`.
|
||||||
|
- `_make_rel_path` in `reader.py` lacked the format prefix (`epub/`, `pdf/`, `comics/`) used by `common.make_rel_path`, causing files to be moved outside their format directory on metadata save. Fixed by aligning the path logic: EPUB → `epub/{publisher}/{author}/…`, PDF → `pdf/{author}/{title}.pdf`, CBR/CBZ → `comics/{author}/{title}{ext}`.
|
||||||
|
- PDF reader showed infinite loading: `reader.html` always called `/library/chapters/{filename}` (EPUB-only) and tried to render chapter text. PDF reader now fetches page count and renders page images.
|
||||||
|
|
||||||
## Known Conventions
|
## Known Conventions
|
||||||
- Book deletion flow: delete file, prune empty directories, then `DELETE FROM library` (cascade removes child rows).
|
- Book deletion flow: delete file, prune empty directories, then `DELETE FROM library` (cascade removes child rows).
|
||||||
- Cover strategy:
|
- Cover strategy:
|
||||||
- EPUB: cover from file + cache
|
- EPUB: cover from file + cache
|
||||||
- PDF/CBR: thumbnail via cover cache
|
- PDF/CBR: thumbnail via cover cache
|
||||||
|
- Rating storage:
|
||||||
|
- EPUB: `<meta name="novela:rating" content="N"/>` in OPF
|
||||||
|
- CBZ: `<NovelaRating>N</NovelaRating>` in `ComicInfo.xml` inside the ZIP
|
||||||
|
- CBR/PDF: DB only
|
||||||
|
- `upsert_book` uses `CASE WHEN EXCLUDED.rating > 0 THEN EXCLUDED.rating ELSE library.rating END` to restore rating from file without overwriting existing DB value
|
||||||
|
|
||||||
## Performance Notes
|
## Performance Notes
|
||||||
- Library load is optimized for large datasets:
|
- Library load is optimized for large datasets:
|
||||||
|
|||||||
@ -45,3 +45,46 @@ This file tracks changes on the `develop` line.
|
|||||||
- dry-run support in the new flow
|
- dry-run support in the new flow
|
||||||
- Updated Docker image with `postgresql-client` for `pg_dump`.
|
- Updated Docker image with `postgresql-client` for `pg_dump`.
|
||||||
- Multiple test builds pushed to `gitea.oskamp.info/ivooskamp/novela:dev`.
|
- Multiple test builds pushed to `gitea.oskamp.info/ivooskamp/novela:dev`.
|
||||||
|
|
||||||
|
## 2026-03-25
|
||||||
|
- Fixed PDF metadata editing (PATCH /library/book):
|
||||||
|
- `_sync_epub_metadata` is now only called for `.epub` files; PDFs update DB only
|
||||||
|
- `_make_rel_path` now includes the format prefix matching import: EPUB → `epub/{publisher}/{author}/…`, PDF → `pdf/{author}/{title}.pdf`, CBR/CBZ → `comics/{author}/{title}{ext}`; previously files were moved outside their format directory on metadata save
|
||||||
|
- Fixed PDF reader (infinite loading screen):
|
||||||
|
- `reader_page` now passes `format` (epub/pdf/cbr/cbz) to `reader.html`
|
||||||
|
- Added `GET /api/pdf/info/{filename}` endpoint returning `{"page_count": N}`
|
||||||
|
- `reader.html` branches on `FORMAT`: PDFs render page images from `/library/pdf/{filename}?page=N`, EPUB flow unchanged
|
||||||
|
- PDF progress tracked per page; keyboard and button navigation work identically to EPUB
|
||||||
|
- `Edit EPUB` button in Book Detail hidden for non-EPUB files
|
||||||
|
|
||||||
|
## 2026-03-23
|
||||||
|
- Added `All books` Grid/List toggle in Library:
|
||||||
|
- same columns as `New` view (Publisher, Author, Series, Volume, Title, Has cover, Updated, Genres, Sub-genres, Tags, Status)
|
||||||
|
- column visibility filter in `List` mode
|
||||||
|
- no selection checkboxes or bulk actions
|
||||||
|
- view mode and column visibility persisted separately in `localStorage` (`novela.all.viewMode`, `novela.all.visibleColumns`)
|
||||||
|
- Added 1–5 star rating for books:
|
||||||
|
- stored in the database (`rating SMALLINT DEFAULT 0`)
|
||||||
|
- written to EPUB OPF as `<meta name="novela:rating" content="N"/>` on rating change
|
||||||
|
- written to CBZ `ComicInfo.xml` as `<NovelaRating>N</NovelaRating>` on rating change
|
||||||
|
- CBR and PDF are DB-only (file format constraints)
|
||||||
|
- rating is recovered from file metadata during rescan/DB rebuild (`upsert_book` preserves file rating over default 0)
|
||||||
|
- stars displayed under the cover (outside `.book-info`) in all grid views (Library, Home)
|
||||||
|
- stars in grid cards are display-only (no click) to prevent accidental taps while scrolling
|
||||||
|
- stars in Book Detail are interactive (larger, 1.1rem), clicking same star removes rating
|
||||||
|
- star color: amber (`#c8a03a`) for filled, `rgba(200, 160, 58, 0.25)` for unfilled — consistent across Library, Home, and Book Detail
|
||||||
|
- Added text colour setting to reader hamburger menu:
|
||||||
|
- 5 warm-tone presets from bright (`#e8e2d9`) to dim (`#938d86`)
|
||||||
|
- active preset shown with accent-coloured ring
|
||||||
|
- choice persisted in `localStorage` (`reader-text-colour`) and restored on next open
|
||||||
|
- Increased spacing between hamburger button and back link in reader header (`margin-left: 1rem`) to prevent accidental taps
|
||||||
|
- Removed `Cover Missing` auto-tag:
|
||||||
|
- tag is no longer added on import, rescan, or grabber download
|
||||||
|
- `ensure_cover_missing_tag()` removed from `common.py`, `library.py`, and `grabber.py`
|
||||||
|
- startup migration removes all existing `Cover Missing` tags from the database
|
||||||
|
- Fixed Tags/Genres/Sub-Genres not saving in book edit panel on desktop:
|
||||||
|
- `,` (comma) now acts as a confirmation key alongside Enter in `PillInput`
|
||||||
|
- `flush()` added to `PillInput`: any text still in the input field is auto-confirmed when Save is clicked
|
||||||
|
- Fixed tag/genre search and tag-pill navigation being broken:
|
||||||
|
- `renderGenreView` was filtering on `b.genres` (non-existent field); now uses `bookGenres()`, `bookSubgenres()`, `bookPlainTags()`
|
||||||
|
- `renderSearchResults` had the same bug; search now covers title, author, genres, sub-genres, and tags
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user