Merge branch v20260326-01 into main
This commit is contained in:
commit
25ececc576
@ -21,48 +21,125 @@ def detect_image_format(data: bytes, base: str) -> tuple[str, str]:
|
||||
|
||||
|
||||
def add_cover_to_epub(epub_path, cover_data: bytes) -> None:
|
||||
"""Add a cover image to an existing EPUB and remove the Cover Missing tag."""
|
||||
"""Replace (or add) the cover image in an existing EPUB."""
|
||||
cover_filename, cover_media_type = detect_image_format(cover_data, "cover")
|
||||
|
||||
# Read existing zip into memory
|
||||
with open(epub_path, "rb") as f:
|
||||
original = f.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(original), "r") as zin:
|
||||
names = zin.namelist()
|
||||
|
||||
# Locate the OPF via META-INF/container.xml
|
||||
opf_path = "OEBPS/content.opf"
|
||||
try:
|
||||
container = zin.read("META-INF/container.xml").decode("utf-8", errors="replace")
|
||||
m = re.search(r'full-path\s*=\s*["\']([^"\']+)["\']', container)
|
||||
if m:
|
||||
opf_path = m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
opf_dir = opf_path.rsplit("/", 1)[0] if "/" in opf_path else ""
|
||||
|
||||
# Parse OPF to find the existing cover image path
|
||||
old_cover_zip_path: str | None = None
|
||||
try:
|
||||
opf_text = zin.read(opf_path).decode("utf-8", errors="replace")
|
||||
# Find item with id="cover*" that is an image
|
||||
for m in re.finditer(
|
||||
r'<item\b[^>]+id=["\']cover[^"\']*["\'][^>]*/?>',
|
||||
opf_text,
|
||||
):
|
||||
href_m = re.search(r'href=["\']([^"\']+)["\']', m.group(0))
|
||||
if href_m:
|
||||
href = href_m.group(1)
|
||||
zip_path = (opf_dir + "/" + href).lstrip("/") if opf_dir else href
|
||||
# Normalise ../ segments
|
||||
parts, resolved = zip_path.split("/"), []
|
||||
for p in parts:
|
||||
if p == ".." and resolved:
|
||||
resolved.pop()
|
||||
else:
|
||||
resolved.append(p)
|
||||
old_cover_zip_path = "/".join(resolved)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Decide where to write the new cover (same folder as old, or Images/ next to OPF)
|
||||
if old_cover_zip_path:
|
||||
cover_dir = old_cover_zip_path.rsplit("/", 1)[0] if "/" in old_cover_zip_path else ""
|
||||
else:
|
||||
cover_dir = (opf_dir + "/Images").lstrip("/") if opf_dir else "OEBPS/Images"
|
||||
new_cover_zip_path = (cover_dir + "/" + cover_filename).lstrip("/")
|
||||
|
||||
# Rebuild the ZIP
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(original), "r") as zin, \
|
||||
zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||
|
||||
# Copy mimetype uncompressed first
|
||||
info = zin.getinfo("mimetype")
|
||||
zout.writestr(zipfile.ZipInfo("mimetype"), zin.read("mimetype"), compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
for item in zin.infolist():
|
||||
if item.filename == "mimetype":
|
||||
continue
|
||||
# Drop the old cover image (will be replaced below)
|
||||
if old_cover_zip_path and item.filename == old_cover_zip_path:
|
||||
continue
|
||||
data = zin.read(item.filename)
|
||||
|
||||
if item.filename == "OEBPS/content.opf":
|
||||
data = _patch_opf(data.decode("utf-8"), cover_filename, cover_media_type).encode("utf-8")
|
||||
|
||||
if item.filename == opf_path:
|
||||
data = _patch_opf(
|
||||
data.decode("utf-8"),
|
||||
cover_filename,
|
||||
cover_media_type,
|
||||
old_cover_zip_path,
|
||||
opf_dir,
|
||||
).encode("utf-8")
|
||||
zout.writestr(item, data)
|
||||
|
||||
# Add the cover image
|
||||
zout.writestr(f"OEBPS/Images/{cover_filename}", cover_data)
|
||||
# Write the new cover image
|
||||
zout.writestr(new_cover_zip_path, cover_data)
|
||||
|
||||
with open(epub_path, "wb") as f:
|
||||
f.write(buf.getvalue())
|
||||
|
||||
|
||||
def _patch_opf(opf: str, cover_filename: str, cover_media_type: str) -> str:
|
||||
"""Insert cover into OPF manifest/metadata and remove Cover Missing dc:subject."""
|
||||
def _patch_opf(
|
||||
opf: str,
|
||||
cover_filename: str,
|
||||
cover_media_type: str,
|
||||
old_cover_zip_path: str | None,
|
||||
opf_dir: str,
|
||||
) -> str:
|
||||
"""Replace or insert the cover manifest item and cover meta in an OPF."""
|
||||
# Remove "Cover Missing" dc:subject
|
||||
opf = re.sub(r'\s*<dc:subject>Cover Missing</dc:subject>', '', opf)
|
||||
|
||||
# Add cover manifest item before </manifest>
|
||||
cover_item = f'<item id="cover-img" href="Images/{cover_filename}" media-type="{cover_media_type}"/>'
|
||||
# Remove existing cover manifest item(s) with id starting with "cover"
|
||||
opf = re.sub(r'\s*<item\b[^>]+id=["\']cover[^"\']*["\'][^>]*/>', '', opf)
|
||||
opf = re.sub(r'\s*<item\b[^>]+id=["\']cover[^"\']*["\'][^>]*></item>', '', opf)
|
||||
# Remove existing <meta name="cover" .../>
|
||||
opf = re.sub(r'\s*<meta\b[^>]+name=["\']cover["\'][^>]*/>', '', opf)
|
||||
|
||||
# Compute relative href from OPF dir to the new cover
|
||||
# new cover is placed in the same folder as the old one, relative to OPF
|
||||
cover_href = cover_filename # same dir as OPF → just the filename
|
||||
if old_cover_zip_path:
|
||||
old_dir = old_cover_zip_path.rsplit("/", 1)[0] if "/" in old_cover_zip_path else ""
|
||||
if old_dir != opf_dir:
|
||||
# Make relative: e.g. opf_dir=EPUB, old_dir=EPUB/images → href=images/cover.jpg
|
||||
if opf_dir and old_dir.startswith(opf_dir + "/"):
|
||||
cover_href = old_dir[len(opf_dir) + 1:] + "/" + cover_filename
|
||||
else:
|
||||
cover_href = cover_filename
|
||||
else:
|
||||
cover_href = cover_filename
|
||||
else:
|
||||
cover_href = "Images/" + cover_filename
|
||||
|
||||
cover_item = f'<item id="cover-img" href="{cover_href}" media-type="{cover_media_type}"/>'
|
||||
opf = opf.replace("</manifest>", f' {cover_item}\n </manifest>')
|
||||
|
||||
# Add cover meta before </metadata>
|
||||
cover_meta = '<meta name="cover" content="cover-img"/>'
|
||||
opf = opf.replace("</metadata>", f' {cover_meta}\n </metadata>')
|
||||
|
||||
|
||||
259
containers/novela/migrate_paths.py
Normal file
259
containers/novela/migrate_paths.py
Normal file
@ -0,0 +1,259 @@
|
||||
"""
|
||||
One-time migration: move all library files to the correct path structure
|
||||
and update all database references.
|
||||
|
||||
Target structure:
|
||||
epub/{publisher}/{author}/Stories/{title}.epub
|
||||
epub/{publisher}/{author}/Series/{series}/{idx:03d} - {title}.epub
|
||||
pdf/{publisher}/{author}/{title}.pdf
|
||||
comics/{publisher}/{author}/{title}.cbr|cbz
|
||||
|
||||
Run inside the novela container:
|
||||
python migrate_paths.py [--execute]
|
||||
Without --execute: dry-run only (no files moved, no DB changes).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
|
||||
LIBRARY_DIR = Path("library")
|
||||
LIBRARY_ROOT = LIBRARY_DIR.resolve()
|
||||
DRY_RUN = "--execute" not in sys.argv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path helpers (mirrors common.py / reader.py logic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _clean(value: str, fallback: str, max_len: int) -> str:
|
||||
txt = re.sub(r"\s+", " ", (value or "").strip())
|
||||
txt = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", txt)
|
||||
txt = re.sub(r"\.+$", "", txt).strip()
|
||||
if not txt:
|
||||
txt = fallback
|
||||
return txt[:max_len]
|
||||
|
||||
|
||||
def _coerce_index(value) -> int:
|
||||
try:
|
||||
return max(1, min(999, int(value or 1)))
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
|
||||
def correct_rel_path(filename: str, title: str, author: str, publisher: str,
|
||||
series: str, series_index: int) -> Path:
|
||||
"""Compute the correct relative path for a book based on current metadata."""
|
||||
ext = Path(filename).suffix.lower()
|
||||
|
||||
pub = _clean(publisher, "Unknown Publisher", 80)
|
||||
auth = _clean(author, "Unknown Author", 80)
|
||||
ttl = _clean(title or Path(filename).stem, "Untitled", 140)
|
||||
|
||||
if ext == ".epub":
|
||||
series_name = _clean(series or "", "", 80)
|
||||
if series_name:
|
||||
idx = _coerce_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") / pub / auth / f"{ttl}.pdf"
|
||||
|
||||
# .cbr / .cbz
|
||||
comics_ext = ext if ext in {".cbr", ".cbz"} else ".cbr"
|
||||
return Path("comics") / pub / auth / f"{ttl}{comics_ext}"
|
||||
|
||||
|
||||
def ensure_unique(rel_path: Path, exclude_current: Path) -> Path:
|
||||
"""Add (2), (3), … suffix if target already exists (and isn't the current file)."""
|
||||
candidate = rel_path
|
||||
counter = 2
|
||||
while True:
|
||||
full = (LIBRARY_DIR / candidate).resolve()
|
||||
if full == exclude_current.resolve():
|
||||
return candidate
|
||||
if not full.exists():
|
||||
return candidate
|
||||
candidate = rel_path.with_name(
|
||||
f"{rel_path.stem} ({counter}){rel_path.suffix}"
|
||||
)
|
||||
counter += 1
|
||||
|
||||
|
||||
def prune_empty_dirs(start: Path) -> None:
|
||||
cur = start.resolve()
|
||||
while cur != LIBRARY_ROOT:
|
||||
try:
|
||||
cur.rmdir()
|
||||
except OSError:
|
||||
return
|
||||
cur = cur.parent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
db_url = (
|
||||
f"host=novela-db "
|
||||
f"dbname={os.environ['POSTGRES_DB']} "
|
||||
f"user={os.environ['POSTGRES_USER']} "
|
||||
f"password={os.environ['POSTGRES_PASSWORD']}"
|
||||
)
|
||||
conn = psycopg2.connect(db_url)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT filename, title, author, publisher, series, series_index
|
||||
FROM library
|
||||
ORDER BY filename
|
||||
""")
|
||||
books = cur.fetchall()
|
||||
|
||||
print(f"Total books in DB: {len(books)}")
|
||||
print(f"Mode: {'DRY RUN' if DRY_RUN else '*** EXECUTE ***'}")
|
||||
print()
|
||||
|
||||
moves = []
|
||||
skipped_missing = []
|
||||
skipped_same = []
|
||||
conflicts = []
|
||||
|
||||
for (filename, title, author, publisher, series, series_index) in books:
|
||||
old_path = (LIBRARY_DIR / filename).resolve()
|
||||
|
||||
new_rel = correct_rel_path(filename, title or "", author or "",
|
||||
publisher or "", series or "", series_index or 0)
|
||||
new_rel = ensure_unique(new_rel, old_path)
|
||||
new_path = (LIBRARY_DIR / new_rel).resolve()
|
||||
|
||||
if not old_path.exists():
|
||||
skipped_missing.append(filename)
|
||||
continue
|
||||
|
||||
if old_path == new_path:
|
||||
skipped_same.append(filename)
|
||||
continue
|
||||
|
||||
# Sanity: target already exists and is a different file
|
||||
if new_path.exists() and new_path != old_path:
|
||||
conflicts.append((filename, new_rel.as_posix()))
|
||||
continue
|
||||
|
||||
moves.append((filename, old_path, new_rel.as_posix(), new_path))
|
||||
|
||||
# Report
|
||||
print(f"Already correct: {len(skipped_same)}")
|
||||
print(f"File missing: {len(skipped_missing)}")
|
||||
print(f"Conflicts: {len(conflicts)}")
|
||||
print(f"To move: {len(moves)}")
|
||||
print()
|
||||
|
||||
if skipped_missing:
|
||||
print("=== MISSING FILES (skipped) ===")
|
||||
for f in skipped_missing:
|
||||
print(f" {f}")
|
||||
print()
|
||||
|
||||
if conflicts:
|
||||
print("=== CONFLICTS (skipped) ===")
|
||||
for old, new in conflicts:
|
||||
print(f" {old}")
|
||||
print(f" → {new} (target exists!)")
|
||||
print()
|
||||
|
||||
if not moves:
|
||||
print("Nothing to do.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print("=== MOVES ===")
|
||||
for old_fn, old_path, new_fn, new_path in moves:
|
||||
print(f" {old_fn}")
|
||||
print(f" → {new_fn}")
|
||||
print()
|
||||
|
||||
if DRY_RUN:
|
||||
print("Dry run complete. Run with --execute to apply changes.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Execute
|
||||
print("Applying changes...")
|
||||
moved = 0
|
||||
errors = []
|
||||
prunable = set()
|
||||
|
||||
for old_fn, old_path, new_fn, new_path in moves:
|
||||
try:
|
||||
# Move file
|
||||
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
old_path.rename(new_path)
|
||||
prunable.add(old_path.parent)
|
||||
|
||||
# Update DB in a transaction
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
# Copy library row with new filename
|
||||
cur.execute("""
|
||||
INSERT INTO library (
|
||||
filename, title, author, publisher, has_cover, media_type,
|
||||
series, series_index, publication_status, want_to_read,
|
||||
source_url, archived, needs_review, updated_at,
|
||||
publish_date, description, rating
|
||||
)
|
||||
SELECT %s, title, author, publisher, has_cover, media_type,
|
||||
series, series_index, publication_status, want_to_read,
|
||||
source_url, archived, needs_review, updated_at,
|
||||
publish_date, description, rating
|
||||
FROM library WHERE filename = %s
|
||||
""", (new_fn, old_fn))
|
||||
|
||||
# Update child tables
|
||||
for table in ("book_tags", "reading_progress",
|
||||
"reading_sessions", "library_cover_cache"):
|
||||
cur.execute(
|
||||
f"UPDATE {table} SET filename = %s WHERE filename = %s",
|
||||
(new_fn, old_fn)
|
||||
)
|
||||
|
||||
# Delete old library row (cascade removes any remaining child rows)
|
||||
cur.execute("DELETE FROM library WHERE filename = %s", (old_fn,))
|
||||
|
||||
moved += 1
|
||||
print(f" [{moved}/{len(moves)}] {old_fn} → {new_fn}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append((old_fn, str(e)))
|
||||
# Try to move file back if DB failed
|
||||
if new_path.exists() and not old_path.exists():
|
||||
try:
|
||||
old_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_path.rename(old_path)
|
||||
except Exception:
|
||||
pass
|
||||
print(f" ERROR: {old_fn}: {e}")
|
||||
|
||||
# Prune empty directories
|
||||
print("\nPruning empty directories...")
|
||||
for d in prunable:
|
||||
prune_empty_dirs(d)
|
||||
|
||||
print()
|
||||
print(f"Done. Moved: {moved}, Errors: {len(errors)}, Skipped (conflict): {len(conflicts)}, Missing: {len(skipped_missing)}")
|
||||
if errors:
|
||||
print("\nErrors:")
|
||||
for fn, err in errors:
|
||||
print(f" {fn}: {err}")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -197,6 +197,23 @@ def migrate_add_rating() -> None:
|
||||
_exec("ALTER TABLE library ADD COLUMN IF NOT EXISTS rating SMALLINT NOT NULL DEFAULT 0")
|
||||
|
||||
|
||||
def migrate_create_bookmarks() -> None:
|
||||
_exec(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
|
||||
chapter_index INTEGER NOT NULL DEFAULT 0,
|
||||
scroll_frac REAL NOT NULL DEFAULT 0,
|
||||
chapter_title VARCHAR(500) NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
_exec("CREATE INDEX IF NOT EXISTS idx_bookmarks_filename ON bookmarks (filename)")
|
||||
|
||||
|
||||
def migrate_remove_cover_missing_tag() -> None:
|
||||
_exec("DELETE FROM book_tags WHERE tag = 'Cover Missing' AND tag_type = 'tag'")
|
||||
|
||||
@ -235,6 +252,15 @@ def migrate_create_perf_indexes() -> None:
|
||||
)
|
||||
|
||||
|
||||
def migrate_series_suffix() -> None:
|
||||
_exec(
|
||||
"""
|
||||
ALTER TABLE library
|
||||
ADD COLUMN IF NOT EXISTS series_suffix VARCHAR(10) NOT NULL DEFAULT ''
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
migrate_create_library()
|
||||
migrate_create_book_tags()
|
||||
@ -248,3 +274,5 @@ def run_migrations() -> None:
|
||||
migrate_seed_break_patterns()
|
||||
migrate_add_rating()
|
||||
migrate_remove_cover_missing_tag()
|
||||
migrate_create_bookmarks()
|
||||
migrate_series_suffix()
|
||||
|
||||
256
containers/novela/recover_decock049.py
Normal file
256
containers/novela/recover_decock049.py
Normal file
@ -0,0 +1,256 @@
|
||||
"""
|
||||
One-time recovery: retrieve 049 - De Cock en het lijk op drift.epub from
|
||||
Dropbox backup, place it at the correct library path, and re-insert the DB row.
|
||||
|
||||
Run inside the novela container:
|
||||
python recover_decock049.py [--execute]
|
||||
Without --execute: dry-run only (shows what would be restored).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import dropbox
|
||||
import psycopg2
|
||||
from security import decrypt_value
|
||||
|
||||
DRY_RUN = "--execute" not in sys.argv
|
||||
LIBRARY_DIR = Path("library")
|
||||
|
||||
TARGET_REL = "epub/Unknown Publisher/A.C. Baantjer/Series/De Cock (Series)/049 - De Cock en het lijk op drift.epub"
|
||||
SEARCH_KEYWORDS = ["de cock", "049", "lijk op drift"]
|
||||
|
||||
|
||||
def _db_conn():
|
||||
return psycopg2.connect(
|
||||
f"host=novela-db "
|
||||
f"dbname={os.environ['POSTGRES_DB']} "
|
||||
f"user={os.environ['POSTGRES_USER']} "
|
||||
f"password={os.environ['POSTGRES_PASSWORD']}"
|
||||
)
|
||||
|
||||
|
||||
def _load_dropbox_token(conn) -> str:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT username, password FROM credentials WHERE site = 'dropbox' LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise RuntimeError("No Dropbox token in credentials table.")
|
||||
username_raw, password_raw = row
|
||||
username = decrypt_value(username_raw)
|
||||
password = decrypt_value(password_raw)
|
||||
token = (password or username or "").strip()
|
||||
if not token:
|
||||
raise RuntimeError("Dropbox token is empty.")
|
||||
return token
|
||||
|
||||
|
||||
def _load_dropbox_root(conn) -> str:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT username, password FROM credentials WHERE site = 'dropbox_backup_root' LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return "/novela"
|
||||
_, password_raw = row
|
||||
root = decrypt_value(password_raw).strip() or "/novela"
|
||||
if not root.startswith("/"):
|
||||
root = "/" + root
|
||||
return root
|
||||
|
||||
|
||||
def _dropbox_join(root: str, *parts: str) -> str:
|
||||
segs = [p.strip("/") for p in parts if p and p.strip("/")]
|
||||
base = root.rstrip("/")
|
||||
return base + "/" + "/".join(segs) if segs else base
|
||||
|
||||
|
||||
def _list_snapshots(client, snapshots_root: str) -> list[str]:
|
||||
paths = []
|
||||
try:
|
||||
res = client.files_list_folder(snapshots_root, recursive=False)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Cannot list snapshots folder '{snapshots_root}': {e}")
|
||||
while True:
|
||||
for entry in res.entries:
|
||||
if isinstance(entry, dropbox.files.FileMetadata):
|
||||
if entry.name.endswith(".json"):
|
||||
paths.append(entry.path_display)
|
||||
if not res.has_more:
|
||||
break
|
||||
res = client.files_list_folder_continue(res.cursor)
|
||||
return sorted(paths, reverse=True) # newest first
|
||||
|
||||
|
||||
def _load_snapshot(client, path: str) -> dict:
|
||||
_meta, resp = client.files_download(path)
|
||||
return json.loads(resp.content.decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
def _find_file_in_snapshot(snap: dict) -> tuple[str, str] | None:
|
||||
"""Return (rel_path, sha256) for De Cock 049, or None."""
|
||||
files = snap.get("files", {})
|
||||
for rel, info in files.items():
|
||||
rel_lower = rel.lower()
|
||||
if all(kw in rel_lower for kw in SEARCH_KEYWORDS):
|
||||
sha256 = info.get("sha256", "")
|
||||
return rel, sha256
|
||||
return None
|
||||
|
||||
|
||||
def _download_object(client, objects_root: str, sha256: str) -> bytes:
|
||||
obj_path = _dropbox_join(objects_root, sha256[:2], sha256)
|
||||
print(f" Downloading object: {obj_path}")
|
||||
_meta, resp = client.files_download(obj_path)
|
||||
return resp.content
|
||||
|
||||
|
||||
def _insert_db_row(conn, filename: str, snap_entry: dict, orig_filename: str) -> None:
|
||||
"""Copy library row from orig_filename if it exists, else insert minimal row."""
|
||||
with conn.cursor() as cur:
|
||||
# Check if orig row exists in DB
|
||||
cur.execute("SELECT * FROM library WHERE filename = %s LIMIT 1", (orig_filename,))
|
||||
orig = cur.fetchone()
|
||||
|
||||
if orig:
|
||||
cols = [desc.name for desc in conn.cursor().description] if False else None
|
||||
# Fetch column names separately
|
||||
with conn.cursor() as cur2:
|
||||
cur2.execute(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name='library' ORDER BY ordinal_position"
|
||||
)
|
||||
cols = [r[0] for r in cur2.fetchall()]
|
||||
|
||||
with conn.cursor() as cur3:
|
||||
cur3.execute(
|
||||
f"SELECT {', '.join(cols)} FROM library WHERE filename = %s LIMIT 1",
|
||||
(orig_filename,),
|
||||
)
|
||||
row = cur3.fetchone()
|
||||
if row:
|
||||
data = dict(zip(cols, row))
|
||||
data["filename"] = filename
|
||||
col_list = ", ".join(data.keys())
|
||||
placeholders = ", ".join(["%s"] * len(data))
|
||||
cur3.execute(
|
||||
f"INSERT INTO library ({col_list}) VALUES ({placeholders}) "
|
||||
f"ON CONFLICT (filename) DO NOTHING",
|
||||
list(data.values()),
|
||||
)
|
||||
print(f" DB row copied from '{orig_filename}' → '{filename}'")
|
||||
return
|
||||
|
||||
# No orig row: insert minimal
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO library (filename, title, author, publisher, series, series_index,
|
||||
media_type, has_cover, needs_review)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (filename) DO NOTHING
|
||||
""",
|
||||
(
|
||||
filename,
|
||||
"De Cock en het lijk op drift",
|
||||
"A.C. Baantjer",
|
||||
"Unknown Publisher",
|
||||
"De Cock (Series)",
|
||||
49,
|
||||
"epub",
|
||||
False,
|
||||
True,
|
||||
),
|
||||
)
|
||||
print(f" DB row inserted (minimal) for '{filename}'")
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Mode: {'DRY RUN' if DRY_RUN else '*** EXECUTE ***'}")
|
||||
print()
|
||||
|
||||
conn = _db_conn()
|
||||
|
||||
token = _load_dropbox_token(conn)
|
||||
dropbox_root = _load_dropbox_root(conn)
|
||||
print(f"Dropbox root: {dropbox_root}")
|
||||
|
||||
client = dropbox.Dropbox(token, timeout=120)
|
||||
try:
|
||||
acct = client.users_get_current_account()
|
||||
print(f"Dropbox account: {acct.email}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: Dropbox auth failed: {e}")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
objects_root = _dropbox_join(dropbox_root, "library_objects")
|
||||
snapshots_root = _dropbox_join(dropbox_root, "library_snapshots")
|
||||
|
||||
print(f"\nListing snapshots in: {snapshots_root}")
|
||||
snapshots = _list_snapshots(client, snapshots_root)
|
||||
print(f"Found {len(snapshots)} snapshots.")
|
||||
for s in snapshots:
|
||||
print(f" {s}")
|
||||
|
||||
found_rel = None
|
||||
found_sha256 = None
|
||||
found_snapshot = None
|
||||
|
||||
for snap_path in snapshots:
|
||||
print(f"\nSearching snapshot: {snap_path}")
|
||||
snap = _load_snapshot(client, snap_path)
|
||||
result = _find_file_in_snapshot(snap)
|
||||
if result:
|
||||
found_rel, found_sha256 = result
|
||||
found_snapshot = snap_path
|
||||
print(f" FOUND: {found_rel}")
|
||||
print(f" sha256: {found_sha256}")
|
||||
break
|
||||
else:
|
||||
print(" Not found in this snapshot.")
|
||||
|
||||
if not found_rel:
|
||||
print("\nERROR: File not found in any snapshot. Cannot recover.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
target_path = LIBRARY_DIR / TARGET_REL
|
||||
print(f"\nTarget path: {target_path}")
|
||||
|
||||
if target_path.exists():
|
||||
print("File already exists at target path. Nothing to do.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if DRY_RUN:
|
||||
print(f"\nDry run: would download sha256={found_sha256}")
|
||||
print(f" and write to: {target_path}")
|
||||
print("\nRun with --execute to apply.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Download
|
||||
data = _download_object(client, objects_root, found_sha256)
|
||||
print(f" Downloaded {len(data):,} bytes.")
|
||||
|
||||
# Write file
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_bytes(data)
|
||||
print(f" Written to: {target_path}")
|
||||
|
||||
# DB
|
||||
with conn:
|
||||
_insert_db_row(conn, TARGET_REL, {}, found_rel)
|
||||
|
||||
print(f"\nDone. File recovered to: {target_path}")
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -7,8 +7,10 @@ import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import dropbox
|
||||
import httpx
|
||||
from dropbox.exceptions import ApiError, AuthError
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
@ -31,6 +33,7 @@ DEFAULT_SCHEDULE_INTERVAL_HOURS = 24
|
||||
|
||||
|
||||
BACKUP_TASKS: dict[int, asyncio.Task] = {}
|
||||
BACKUP_PROGRESS: dict[int, dict] = {} # log_id → {done, total, phase}
|
||||
SCHEDULER_TASK: asyncio.Task | None = None
|
||||
|
||||
|
||||
@ -95,6 +98,66 @@ def _load_dropbox_token() -> str:
|
||||
return _dropbox_credential_details().get("token", "")
|
||||
|
||||
|
||||
def _load_dropbox_app_key() -> str:
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT password FROM credentials WHERE site = 'dropbox_app_key' LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return ""
|
||||
return decrypt_value(row[0]).strip()
|
||||
|
||||
|
||||
def _load_dropbox_app_secret() -> str:
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT password FROM credentials WHERE site = 'dropbox_app_secret' LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return ""
|
||||
return decrypt_value(row[0]).strip()
|
||||
|
||||
|
||||
def _save_dropbox_app_key(app_key: str) -> None:
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO credentials (site, username, password, updated_at)
|
||||
VALUES ('dropbox_app_key', %s, %s, NOW())
|
||||
ON CONFLICT (site) DO UPDATE
|
||||
SET username = EXCLUDED.username,
|
||||
password = EXCLUDED.password,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(encrypt_value(""), encrypt_value(app_key.strip())),
|
||||
)
|
||||
|
||||
|
||||
def _save_dropbox_app_secret(app_secret: str) -> None:
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO credentials (site, username, password, updated_at)
|
||||
VALUES ('dropbox_app_secret', %s, %s, NOW())
|
||||
ON CONFLICT (site) DO UPDATE
|
||||
SET username = EXCLUDED.username,
|
||||
password = EXCLUDED.password,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(encrypt_value(""), encrypt_value(app_secret.strip())),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_dropbox_root(value: str | None) -> str:
|
||||
root = (value or "").strip() or DEFAULT_DROPBOX_ROOT
|
||||
if not root.startswith("/"):
|
||||
@ -325,14 +388,36 @@ def _save_dropbox_retention_count(retention_count: int) -> None:
|
||||
|
||||
|
||||
def _dbx() -> dropbox.Dropbox:
|
||||
"""
|
||||
Maak een Dropbox client aan.
|
||||
|
||||
Voorkeursvolgorde:
|
||||
1. App key + app secret + refresh token -> automatische token refresh
|
||||
2. Legacy access token (achterwaartse compatibiliteit)
|
||||
"""
|
||||
token = _load_dropbox_token()
|
||||
if not token:
|
||||
raise RuntimeError("Dropbox token not found in credentials (site='dropbox').")
|
||||
client = dropbox.Dropbox(token, timeout=120)
|
||||
|
||||
app_key = _load_dropbox_app_key()
|
||||
app_secret = _load_dropbox_app_secret()
|
||||
|
||||
try:
|
||||
if app_key and app_secret:
|
||||
client = dropbox.Dropbox(
|
||||
oauth2_refresh_token=token,
|
||||
app_key=app_key,
|
||||
app_secret=app_secret,
|
||||
timeout=120,
|
||||
)
|
||||
else:
|
||||
# Fallback: legacy access token
|
||||
client = dropbox.Dropbox(token, timeout=120)
|
||||
|
||||
client.users_get_current_account()
|
||||
except AuthError as e:
|
||||
raise RuntimeError(f"Dropbox auth failed: {e}")
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@ -586,10 +671,17 @@ def _prune_orphan_objects(client: dropbox.Dropbox, objects_root: str, referenced
|
||||
return _dropbox_delete_paths(client, to_delete)
|
||||
|
||||
|
||||
def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]:
|
||||
def _run_backup_internal(*, dry_run: bool, progress_key: int | None = None) -> tuple[int, int]:
|
||||
def _prog(done: int, total: int, phase: str) -> None:
|
||||
if progress_key is not None:
|
||||
BACKUP_PROGRESS[progress_key] = {"done": done, "total": total, "phase": phase}
|
||||
|
||||
client = None if dry_run else _dbx()
|
||||
manifest = _load_manifest()
|
||||
files = _iter_library_files()
|
||||
total_files = len(files)
|
||||
|
||||
_prog(0, total_files, "scanning")
|
||||
|
||||
uploaded_count = 0
|
||||
uploaded_size = 0
|
||||
@ -607,7 +699,8 @@ def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]:
|
||||
|
||||
snapshot_files: dict[str, dict[str, float | int | str]] = {}
|
||||
|
||||
for path in files:
|
||||
for idx, path in enumerate(files):
|
||||
_prog(idx, total_files, "uploading")
|
||||
rel = path.relative_to(LIBRARY_DIR).as_posix()
|
||||
state = _current_file_state(path)
|
||||
prev = manifest.get(rel, {}) if isinstance(manifest.get(rel), dict) else {}
|
||||
@ -639,6 +732,8 @@ def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]:
|
||||
uploaded_size += int(state["size"])
|
||||
uploaded_count += 1
|
||||
|
||||
_prog(total_files, total_files, "snapshot")
|
||||
|
||||
snapshot = {
|
||||
"created_at": _now_iso(),
|
||||
"retention_count": retention_count,
|
||||
@ -661,6 +756,8 @@ def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]:
|
||||
uploaded_size += len(snapshot_data)
|
||||
uploaded_count += 1
|
||||
|
||||
_prog(total_files, total_files, "pg_dump")
|
||||
|
||||
dump_data, dump_name = _run_pg_dump()
|
||||
dump_target = _dropbox_join(dropbox_root, "postgres", dump_name)
|
||||
if client is not None:
|
||||
@ -691,10 +788,15 @@ async def backup_dropbox_credentials():
|
||||
preview = ""
|
||||
if token:
|
||||
preview = f"{token[:4]}...{token[-4:]}" if len(token) >= 10 else "(configured)"
|
||||
|
||||
app_key = _load_dropbox_app_key()
|
||||
app_secret = _load_dropbox_app_secret()
|
||||
|
||||
return {
|
||||
"configured": bool(token),
|
||||
"token_preview": preview,
|
||||
"updated_at": details.get("updated_at"),
|
||||
"app_key_configured": bool(app_key and app_secret),
|
||||
"dropbox_root": root_details.get("root", DEFAULT_DROPBOX_ROOT),
|
||||
"root_updated_at": root_details.get("updated_at"),
|
||||
"retention_count": int(retention_details.get("retention_count", DEFAULT_RETENTION_COUNT)),
|
||||
@ -719,6 +821,9 @@ async def backup_dropbox_credentials_save(request: Request):
|
||||
if not token:
|
||||
return {"ok": False, "error": "Dropbox token is required."}
|
||||
|
||||
app_key = (body.get("app_key") or "").strip()
|
||||
app_secret = (body.get("app_secret") or "").strip()
|
||||
|
||||
dropbox_root = _normalize_dropbox_root(body.get("dropbox_root") or _load_dropbox_root())
|
||||
raw_retention = body.get("retention_count", _load_dropbox_retention_count())
|
||||
try:
|
||||
@ -748,6 +853,11 @@ async def backup_dropbox_credentials_save(request: Request):
|
||||
(encrypt_value(""), encrypt_value(token)),
|
||||
)
|
||||
|
||||
if app_key:
|
||||
_save_dropbox_app_key(app_key)
|
||||
if app_secret:
|
||||
_save_dropbox_app_secret(app_secret)
|
||||
|
||||
_save_dropbox_root(dropbox_root)
|
||||
_save_dropbox_retention_count(retention_count)
|
||||
_save_backup_schedule(schedule_enabled, schedule_interval_hours)
|
||||
@ -768,7 +878,14 @@ async def backup_dropbox_credentials_delete():
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM credentials WHERE site IN ('dropbox', 'dropbox_backup_root', 'dropbox_backup_retention', 'dropbox_backup_schedule')"
|
||||
"""DELETE FROM credentials WHERE site IN (
|
||||
'dropbox',
|
||||
'dropbox_app_key',
|
||||
'dropbox_app_secret',
|
||||
'dropbox_backup_root',
|
||||
'dropbox_backup_retention',
|
||||
'dropbox_backup_schedule'
|
||||
)"""
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
@ -797,6 +914,8 @@ async def backup_health():
|
||||
"dropbox_error": dropbox_error,
|
||||
"dropbox_root": dropbox_root,
|
||||
"retention_count": retention_count,
|
||||
"schedule_enabled": schedule_enabled,
|
||||
"schedule_interval_hours": schedule_interval_hours,
|
||||
"pg_dump_available": bool(pg_dump_path),
|
||||
"pg_dump_path": pg_dump_path,
|
||||
"library_exists": LIBRARY_DIR.exists(),
|
||||
@ -917,8 +1036,11 @@ async def stop_backup_scheduler() -> None:
|
||||
|
||||
|
||||
async def _run_backup_job(log_id: int, dry_run: bool) -> None:
|
||||
BACKUP_PROGRESS[log_id] = {"done": 0, "total": 0, "phase": "starting"}
|
||||
try:
|
||||
files_count, size_bytes = await asyncio.to_thread(_run_backup_internal, dry_run=dry_run)
|
||||
files_count, size_bytes = await asyncio.to_thread(
|
||||
_run_backup_internal, dry_run=dry_run, progress_key=log_id
|
||||
)
|
||||
_finish_backup_log(
|
||||
log_id,
|
||||
status="success",
|
||||
@ -936,6 +1058,115 @@ async def _run_backup_job(log_id: int, dry_run: bool) -> None:
|
||||
)
|
||||
finally:
|
||||
BACKUP_TASKS.pop(log_id, None)
|
||||
BACKUP_PROGRESS.pop(log_id, None)
|
||||
|
||||
|
||||
@router.post("/api/backup/oauth/prepare")
|
||||
async def oauth_prepare(request: Request):
|
||||
"""
|
||||
Sla app key + secret op en geef de Dropbox autorisatie-URL terug.
|
||||
De gebruiker opent deze URL in de browser en krijgt een code te zien.
|
||||
Gebruikt token_access_type=offline voor een refresh token dat niet verloopt.
|
||||
"""
|
||||
body = {}
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app_key = (body.get("app_key") or "").strip()
|
||||
app_secret = (body.get("app_secret") or "").strip()
|
||||
|
||||
if not app_key or not app_secret:
|
||||
return {"ok": False, "error": "app_key and app_secret are required."}
|
||||
|
||||
_save_dropbox_app_key(app_key)
|
||||
_save_dropbox_app_secret(app_secret)
|
||||
|
||||
params = urlencode({
|
||||
"client_id": app_key,
|
||||
"response_type": "code",
|
||||
"token_access_type": "offline",
|
||||
})
|
||||
auth_url = f"https://www.dropbox.com/oauth2/authorize?{params}"
|
||||
|
||||
return {"ok": True, "auth_url": auth_url}
|
||||
|
||||
|
||||
@router.post("/api/backup/oauth/exchange")
|
||||
async def oauth_exchange(request: Request):
|
||||
"""
|
||||
Wissel de door de gebruiker ingevoerde autorisatiecode in voor een refresh token.
|
||||
Slaat het refresh token op als het Dropbox-token.
|
||||
"""
|
||||
body = {}
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
code = (body.get("code") or "").strip()
|
||||
if not code:
|
||||
return {"ok": False, "error": "Authorization code is required."}
|
||||
|
||||
app_key = _load_dropbox_app_key()
|
||||
app_secret = _load_dropbox_app_secret()
|
||||
|
||||
if not app_key or not app_secret:
|
||||
return {"ok": False, "error": "App key and secret not found. Run prepare step first."}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
"https://api.dropbox.com/oauth2/token",
|
||||
data={
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
auth=(app_key, app_secret),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "error": f"Dropbox API error: {e.response.status_code} {e.response.text[:200]}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
refresh_token = data.get("refresh_token", "").strip()
|
||||
if not refresh_token:
|
||||
return {"ok": False, "error": "No refresh token in Dropbox response. Make sure token_access_type=offline was used."}
|
||||
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO credentials (site, username, password, updated_at)
|
||||
VALUES ('dropbox', %s, %s, NOW())
|
||||
ON CONFLICT (site) DO UPDATE
|
||||
SET username = EXCLUDED.username,
|
||||
password = EXCLUDED.password,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(encrypt_value(""), encrypt_value(refresh_token)),
|
||||
)
|
||||
|
||||
return {"ok": True, "message": "Refresh token saved. Dropbox is now connected."}
|
||||
|
||||
|
||||
@router.get("/api/backup/progress")
|
||||
async def backup_progress():
|
||||
if not BACKUP_PROGRESS:
|
||||
return {"running": False}
|
||||
log_id = max(BACKUP_PROGRESS.keys())
|
||||
p = BACKUP_PROGRESS[log_id]
|
||||
return {
|
||||
"running": True,
|
||||
"log_id": log_id,
|
||||
"done": p.get("done", 0),
|
||||
"total": p.get("total", 0),
|
||||
"phase": p.get("phase", ""),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/backup/run")
|
||||
|
||||
@ -52,21 +52,41 @@ def media_type_from_suffix(path: Path) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def parse_volume_str(value: int | str | None) -> tuple[int, str]:
|
||||
"""Parse a volume string like '21a' or '0' into (index, suffix).
|
||||
|
||||
Returns (0, '') for anything unparseable.
|
||||
index is clamped to 0–999; suffix is lowercased alpha only, max 5 chars.
|
||||
"""
|
||||
s = str(value or "").strip()
|
||||
m = re.match(r"^(\d+)([a-zA-Z]*)$", s)
|
||||
if m:
|
||||
idx = max(0, min(999, int(m.group(1))))
|
||||
suffix = m.group(2).lower()[:5]
|
||||
return idx, suffix
|
||||
try:
|
||||
return max(0, min(999, int(float(s)))), ""
|
||||
except Exception:
|
||||
return 0, ""
|
||||
|
||||
|
||||
def coerce_series_index(value: int | str | None) -> int:
|
||||
try:
|
||||
return max(1, min(999, int(value or 1)))
|
||||
return max(0, min(999, int(value or 0)))
|
||||
except Exception:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def make_rel_path(*, media_type: str, publisher: str, author: str, title: str, series: str, series_index: int | str | None, ext: str = "") -> Path:
|
||||
def make_rel_path(*, media_type: str, publisher: str, author: str, title: str, series: str, series_index: int | str | None, series_suffix: str = "", ext: str = "") -> Path:
|
||||
if media_type == "epub":
|
||||
pub = clean_segment(publisher, "Unknown Publisher", 80)
|
||||
auth = clean_segment(author, "Unknown Author", 80)
|
||||
ttl = clean_segment(title, "Untitled", 140)
|
||||
series_name = clean_segment(series, "", 80)
|
||||
if series_name:
|
||||
return Path("epub") / pub / auth / "Series" / series_name / f"{coerce_series_index(series_index):03d} - {ttl}.epub"
|
||||
idx = coerce_series_index(series_index)
|
||||
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5]
|
||||
return Path("epub") / pub / auth / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}.epub"
|
||||
return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub"
|
||||
|
||||
if media_type == "pdf":
|
||||
@ -195,6 +215,7 @@ def scan_epub(path: Path) -> dict:
|
||||
"has_cover": False,
|
||||
"series": "",
|
||||
"series_index": 0,
|
||||
"series_suffix": "",
|
||||
"title": "",
|
||||
"publication_status": "",
|
||||
"author": "",
|
||||
@ -233,6 +254,9 @@ def scan_epub(path: Path) -> dict:
|
||||
out["series_index"] = int(float(m.group(1)))
|
||||
except Exception:
|
||||
out["series_index"] = 0
|
||||
m = re.search(r'<meta[^>]*name="novela:series_suffix"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
|
||||
if m:
|
||||
out["series_suffix"] = re.sub(r"[^a-z]", "", m.group(1).lower())[:5]
|
||||
m = re.search(r'<meta[^>]*name="publication_status"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
|
||||
if m:
|
||||
out["publication_status"] = _html.unescape(m.group(1).strip())
|
||||
@ -311,9 +335,9 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO library (filename, media_type, title, author, publisher, has_cover,
|
||||
series, series_index, publication_status, source_url,
|
||||
series, series_index, series_suffix, publication_status, source_url,
|
||||
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())
|
||||
VALUES (%s, %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),
|
||||
@ -322,6 +346,7 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N
|
||||
has_cover = (library.has_cover OR EXCLUDED.has_cover),
|
||||
series = COALESCE(NULLIF(EXCLUDED.series, ''), library.series),
|
||||
series_index = CASE WHEN COALESCE(EXCLUDED.series_index, 0) > 0 THEN EXCLUDED.series_index ELSE library.series_index END,
|
||||
series_suffix = COALESCE(NULLIF(EXCLUDED.series_suffix, ''), library.series_suffix),
|
||||
publication_status = COALESCE(NULLIF(EXCLUDED.publication_status, ''), library.publication_status),
|
||||
source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), library.source_url),
|
||||
publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date),
|
||||
@ -338,6 +363,7 @@ def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | N
|
||||
bool(meta.get("has_cover", False)),
|
||||
meta.get("series", ""),
|
||||
meta.get("series_index", 0),
|
||||
meta.get("series_suffix", ""),
|
||||
meta.get("publication_status", ""),
|
||||
meta.get("source_url", ""),
|
||||
meta.get("publish_date") or None,
|
||||
@ -380,7 +406,8 @@ def list_library_json() -> list[dict]:
|
||||
COALESCE(rs.read_count, 0)::int AS read_count,
|
||||
rs.last_read,
|
||||
(cc.filename IS NOT NULL) AS has_cached_cover,
|
||||
l.rating
|
||||
l.rating,
|
||||
COALESCE(l.series_suffix, '') AS series_suffix
|
||||
FROM library l
|
||||
LEFT JOIN reading_progress rp ON rp.filename = l.filename
|
||||
LEFT JOIN (
|
||||
@ -413,6 +440,7 @@ def list_library_json() -> list[dict]:
|
||||
"has_cached_cover": bool(r[18]),
|
||||
"series": r[6] or "",
|
||||
"series_index": r[7] or 0,
|
||||
"series_suffix": r[20] or "",
|
||||
"publication_status": r[8] or "",
|
||||
"want_to_read": bool(r[9]),
|
||||
"archived": bool(r[10]),
|
||||
|
||||
@ -219,10 +219,30 @@ async def library_cover(filename: str):
|
||||
if mt == "epub":
|
||||
from routers.common import extract_cover_from_epub
|
||||
|
||||
# Serve from cache when available (e.g. after a cover upload)
|
||||
with get_db_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT thumb_webp, mime_type FROM library_cover_cache WHERE filename = %s",
|
||||
(filename,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
return Response(content=bytes(row[0]), media_type=row[1] or "image/webp")
|
||||
|
||||
# Fall back to extracting directly from the EPUB file
|
||||
extracted = extract_cover_from_epub(full)
|
||||
if not extracted:
|
||||
return Response(status_code=404)
|
||||
raw, mime = extracted
|
||||
# Warm the cache for next time
|
||||
try:
|
||||
thumb = make_cover_thumb_webp(raw)
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
upsert_cover_cache(conn, filename, "image/webp", thumb)
|
||||
except Exception:
|
||||
pass
|
||||
return Response(content=raw, media_type=mime)
|
||||
|
||||
if mt in {"pdf", "cbr"}:
|
||||
|
||||
@ -14,7 +14,7 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from cbr import cbr_get_page
|
||||
from cbr import cbr_get_page, cbr_page_count
|
||||
from db import get_db_conn
|
||||
from epub import read_epub_file, write_epub_file
|
||||
from pdf import pdf_page_count, pdf_render_page
|
||||
@ -267,6 +267,7 @@ def _sync_epub_metadata(
|
||||
description: str,
|
||||
series: str,
|
||||
series_index: int | str | None,
|
||||
series_suffix: str = "",
|
||||
subjects: list[str],
|
||||
) -> None:
|
||||
"""Write edited metadata back into OPF so DB and EPUB stay aligned."""
|
||||
@ -344,8 +345,11 @@ def _sync_epub_metadata(
|
||||
set_named_meta('calibre:series', series_val)
|
||||
if series_val:
|
||||
set_named_meta('calibre:series_index', str(_coerce_series_index(series_index)))
|
||||
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5]
|
||||
set_named_meta('novela:series_suffix', sfx)
|
||||
else:
|
||||
set_named_meta('calibre:series_index', '')
|
||||
set_named_meta('novela:series_suffix', '')
|
||||
|
||||
_rewrite_epub_entries(epub_path, {opf_path: str(opf).encode('utf-8')})
|
||||
|
||||
@ -391,9 +395,9 @@ def _clean_segment(value: str, fallback: str, max_len: int = 100) -> str:
|
||||
|
||||
def _coerce_series_index(value: int | str | None) -> int:
|
||||
try:
|
||||
return max(1, min(999, int(value or 1)))
|
||||
return max(0, min(999, int(value or 0)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _make_rel_path(
|
||||
@ -403,6 +407,7 @@ def _make_rel_path(
|
||||
title: str,
|
||||
series: str,
|
||||
series_index: int | str | None,
|
||||
series_suffix: str = "",
|
||||
ext: str = ".epub",
|
||||
) -> Path:
|
||||
auth = _clean_segment(author, "Unknown Author", 80)
|
||||
@ -413,7 +418,8 @@ def _make_rel_path(
|
||||
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"
|
||||
sfx = re.sub(r"[^a-z]", "", (series_suffix or "").lower())[:5]
|
||||
return Path("epub") / pub / auth / "Series" / series_name / f"{idx:03d}{sfx} - {ttl}.epub"
|
||||
return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub"
|
||||
|
||||
if ext == ".pdf":
|
||||
@ -500,7 +506,7 @@ async def get_chapter_html(filename: str, index: int):
|
||||
resolved.pop()
|
||||
else:
|
||||
resolved.append(p)
|
||||
img["src"] = f"/library/chapter-img/{'/'.join(resolved[1:])}?filename={filename}"
|
||||
img["src"] = f"/library/chapter-img/{'/'.join(resolved)}?filename={filename}"
|
||||
return Response(str(body), media_type="text/html")
|
||||
|
||||
|
||||
@ -514,7 +520,16 @@ async def get_chapter_image(path: str, filename: str):
|
||||
return Response(status_code=404)
|
||||
try:
|
||||
with zf.ZipFile(epub_path, "r") as z:
|
||||
data = z.read("OEBPS/" + path)
|
||||
names = z.namelist()
|
||||
if path in names:
|
||||
data = z.read(path)
|
||||
else:
|
||||
# Case-insensitive fallback
|
||||
target = path.lower()
|
||||
match = next((n for n in names if n.lower() == target), None)
|
||||
if match is None:
|
||||
return Response(status_code=404)
|
||||
data = z.read(match)
|
||||
except KeyError:
|
||||
return Response(status_code=404)
|
||||
ext = path.rsplit(".", 1)[-1].lower()
|
||||
@ -626,7 +641,7 @@ async def book_detail_page(filename: str, request: Request):
|
||||
"""
|
||||
SELECT title, author, publisher, has_cover, series, series_index,
|
||||
publication_status, want_to_read, source_url, archived, publish_date, description,
|
||||
rating
|
||||
rating, COALESCE(series_suffix, '') AS series_suffix
|
||||
FROM library WHERE filename = %s
|
||||
""",
|
||||
(filename,),
|
||||
@ -640,6 +655,7 @@ async def book_detail_page(filename: str, request: Request):
|
||||
"has_cover": lib_row[3] or False,
|
||||
"series": lib_row[4] or "",
|
||||
"series_index": lib_row[5] or 0,
|
||||
"series_suffix": lib_row[13] or "",
|
||||
"publication_status": lib_row[6] or "",
|
||||
"want_to_read": lib_row[7] or False,
|
||||
"source_url": lib_row[8] or "",
|
||||
@ -703,6 +719,15 @@ async def book_detail_page(filename: str, request: Request):
|
||||
row = cur.fetchone()
|
||||
progress = row[1] or 0 if row else 0
|
||||
cfi = row[0] if row else None
|
||||
|
||||
series_is_indexed = False
|
||||
if entry.get("series"):
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM library WHERE series = %s AND series_index > 0",
|
||||
(entry["series"],),
|
||||
)
|
||||
series_is_indexed = (cur.fetchone()[0] or 0) > 0
|
||||
|
||||
return templates.TemplateResponse(request, "book.html", {
|
||||
"active": "book",
|
||||
"filename": filename,
|
||||
@ -710,6 +735,7 @@ async def book_detail_page(filename: str, request: Request):
|
||||
"author": entry["author"],
|
||||
"series": entry["series"],
|
||||
"series_index": entry["series_index"],
|
||||
"series_suffix": entry["series_suffix"],
|
||||
"genres": genres,
|
||||
"subgenres": subgenres,
|
||||
"tags": tags_list,
|
||||
@ -726,6 +752,7 @@ async def book_detail_page(filename: str, request: Request):
|
||||
"progress": progress,
|
||||
"cfi": cfi,
|
||||
"rating": entry.get("rating", 0),
|
||||
"series_is_indexed": series_is_indexed,
|
||||
})
|
||||
|
||||
|
||||
@ -752,6 +779,21 @@ async def api_genres(type: str | None = None):
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@router.get("/api/suggestions")
|
||||
async def api_suggestions(type: str | None = None):
|
||||
"""Return distinct non-empty values for author, publisher, or series, sorted alphabetically."""
|
||||
col_map = {"author": "author", "publisher": "publisher", "series": "series"}
|
||||
col = col_map.get(type or "")
|
||||
if not col:
|
||||
return JSONResponse([])
|
||||
with get_db_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT DISTINCT {col} FROM library WHERE {col} IS NOT NULL AND {col} <> '' ORDER BY {col}"
|
||||
)
|
||||
return JSONResponse([r[0] for r in cur.fetchall()])
|
||||
|
||||
|
||||
@router.patch("/library/book/{filename:path}")
|
||||
async def book_update(filename: str, request: Request):
|
||||
"""Update book metadata and tags, and rename/move the file when needed."""
|
||||
@ -764,7 +806,8 @@ async def book_update(filename: str, request: Request):
|
||||
author = body.get("author", "")
|
||||
publisher = body.get("publisher", "")
|
||||
series = body.get("series", "")
|
||||
series_index = _coerce_series_index(body.get("series_index", 1))
|
||||
from routers.common import parse_volume_str
|
||||
series_index, series_suffix = parse_volume_str(body.get("series_index", ""))
|
||||
ext = old_path.suffix.lower()
|
||||
|
||||
target_rel = _make_rel_path(
|
||||
@ -773,6 +816,7 @@ async def book_update(filename: str, request: Request):
|
||||
title=title,
|
||||
series=series,
|
||||
series_index=series_index,
|
||||
series_suffix=series_suffix,
|
||||
ext=ext,
|
||||
)
|
||||
target_rel = _ensure_unique_rel_path(target_rel, exclude=old_path)
|
||||
@ -798,6 +842,7 @@ async def book_update(filename: str, request: Request):
|
||||
description=body.get("description", ""),
|
||||
series=series,
|
||||
series_index=series_index if series else 0,
|
||||
series_suffix=series_suffix if series else "",
|
||||
subjects=(body.get("genres", []) + body.get("subgenres", []) + body.get("tags", [])),
|
||||
)
|
||||
|
||||
@ -812,17 +857,18 @@ async def book_update(filename: str, request: Request):
|
||||
"""
|
||||
INSERT INTO library (
|
||||
filename, title, author, publisher, has_cover,
|
||||
series, series_index, publication_status,
|
||||
series, series_index, series_suffix, publication_status,
|
||||
source_url, publish_date, description,
|
||||
archived, needs_review, updated_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, FALSE, NOW())
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, FALSE, NOW())
|
||||
ON CONFLICT (filename) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
author = EXCLUDED.author,
|
||||
publisher = EXCLUDED.publisher,
|
||||
series = EXCLUDED.series,
|
||||
series_index = EXCLUDED.series_index,
|
||||
series_suffix = EXCLUDED.series_suffix,
|
||||
publication_status = EXCLUDED.publication_status,
|
||||
source_url = EXCLUDED.source_url,
|
||||
publish_date = EXCLUDED.publish_date,
|
||||
@ -838,6 +884,7 @@ async def book_update(filename: str, request: Request):
|
||||
has_cover,
|
||||
series,
|
||||
series_index if series else 0,
|
||||
series_suffix if series else "",
|
||||
body.get("publication_status", ""),
|
||||
body.get("source_url", ""),
|
||||
body.get("publish_date") or None,
|
||||
@ -959,8 +1006,20 @@ async def library_pdf_page(filename: str, page: int = 0, dpi: int = 150):
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/library/cbr/{filename:path}/{page:int}")
|
||||
async def library_cbr_page(filename: str, page: int):
|
||||
@router.get("/api/cbr/info/{filename:path}")
|
||||
async def cbr_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 = cbr_page_count(path)
|
||||
return JSONResponse({"page_count": count})
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/library/cbr/{filename:path}")
|
||||
async def library_cbr_page(filename: str, page: int = 0):
|
||||
path = resolve_library_path(filename)
|
||||
if path is None:
|
||||
return JSONResponse({"error": "Invalid filename"}, status_code=400)
|
||||
@ -975,3 +1034,123 @@ async def library_cbr_page(filename: str, page: int):
|
||||
return JSONResponse({"error": "Page out of range"}, status_code=416)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bookmark routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/library/bookmarks/{filename:path}")
|
||||
async def get_bookmarks(filename: str):
|
||||
if resolve_library_path(filename) is None:
|
||||
return JSONResponse({"error": "Invalid filename"}, status_code=400)
|
||||
with get_db_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, chapter_index, scroll_frac, chapter_title, note,
|
||||
created_at AT TIME ZONE 'UTC'
|
||||
FROM bookmarks WHERE filename = %s ORDER BY created_at DESC
|
||||
""",
|
||||
(filename,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return JSONResponse([
|
||||
{
|
||||
"id": r[0],
|
||||
"chapter_index": r[1],
|
||||
"scroll_frac": r[2],
|
||||
"chapter_title": r[3],
|
||||
"note": r[4],
|
||||
"created_at": r[5].isoformat() + "Z" if r[5] else None,
|
||||
}
|
||||
for r in rows
|
||||
])
|
||||
|
||||
|
||||
@router.post("/library/bookmarks/{filename:path}")
|
||||
async def add_bookmark(filename: str, request: Request):
|
||||
if resolve_library_path(filename) is None:
|
||||
return JSONResponse({"error": "Invalid filename"}, status_code=400)
|
||||
body = await request.json()
|
||||
chapter_index = int(body.get("chapter_index", 0))
|
||||
scroll_frac = float(body.get("scroll_frac", 0.0))
|
||||
chapter_title = str(body.get("chapter_title", ""))[:500]
|
||||
note = str(body.get("note", ""))
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO bookmarks (filename, chapter_index, scroll_frac, chapter_title, note)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id, created_at AT TIME ZONE 'UTC'
|
||||
""",
|
||||
(filename, chapter_index, scroll_frac, chapter_title, note),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"id": row[0],
|
||||
"created_at": row[1].isoformat() + "Z" if row[1] else None,
|
||||
})
|
||||
|
||||
|
||||
@router.patch("/library/bookmarks/{bookmark_id}")
|
||||
async def update_bookmark(bookmark_id: int, request: Request):
|
||||
body = await request.json()
|
||||
note = str(body.get("note", ""))
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE bookmarks SET note = %s WHERE id = %s",
|
||||
(note, bookmark_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.delete("/library/bookmarks/{bookmark_id}")
|
||||
async def delete_bookmark(bookmark_id: int):
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM bookmarks WHERE id = %s", (bookmark_id,))
|
||||
if cur.rowcount == 0:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/bookmarks")
|
||||
async def api_all_bookmarks():
|
||||
"""Return all bookmarks across all books, enriched with book title/author."""
|
||||
with get_db_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT b.id, b.filename, b.chapter_index, b.scroll_frac,
|
||||
b.chapter_title, b.note,
|
||||
b.created_at AT TIME ZONE 'UTC',
|
||||
l.title, l.author
|
||||
FROM bookmarks b
|
||||
LEFT JOIN library l ON l.filename = b.filename
|
||||
ORDER BY b.created_at DESC
|
||||
""",
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return JSONResponse([
|
||||
{
|
||||
"id": r[0],
|
||||
"filename": r[1],
|
||||
"chapter_index": r[2],
|
||||
"scroll_frac": r[3],
|
||||
"chapter_title": r[4],
|
||||
"note": r[5],
|
||||
"created_at": r[6].isoformat() + "Z" if r[6] else None,
|
||||
"book_title": r[7] or r[1],
|
||||
"book_author": r[8] or "",
|
||||
}
|
||||
for r in rows
|
||||
])
|
||||
|
||||
@ -261,24 +261,89 @@ const genreInput = new PillInput('genre-box', 'genre-input', 'genre-dro
|
||||
const subgenreInput = new PillInput('subgenre-box', 'subgenre-input', 'subgenre-dropdown');
|
||||
const tagInput = new PillInput('tag-box', 'tag-input', 'tag-dropdown');
|
||||
|
||||
// ── TextSuggest — single-value autocomplete for plain text inputs ───────────
|
||||
|
||||
class TextSuggest {
|
||||
constructor(inputId, dropdownId) {
|
||||
this.input = document.getElementById(inputId);
|
||||
this.dropdown = document.getElementById(dropdownId);
|
||||
this.all = [];
|
||||
this.ddIndex = -1;
|
||||
this.input.addEventListener('input', () => this._onInput());
|
||||
this.input.addEventListener('keydown', (e) => this._onKeydown(e));
|
||||
this.input.addEventListener('blur', () => setTimeout(() => this._hide(), 150));
|
||||
}
|
||||
|
||||
setSuggestions(all) { this.all = all; }
|
||||
|
||||
_show(items) {
|
||||
if (!items.length) { this._hide(); return; }
|
||||
this.dropdown.innerHTML = items.map(v =>
|
||||
`<div class="genre-option" data-val="${v.replace(/"/g,'"')}">${v}</div>`
|
||||
).join('');
|
||||
this.dropdown.querySelectorAll('.genre-option').forEach(el => {
|
||||
el.onmousedown = (e) => { e.preventDefault(); this.input.value = el.dataset.val; this._hide(); };
|
||||
});
|
||||
this.dropdown.style.display = 'block';
|
||||
this.ddIndex = -1;
|
||||
}
|
||||
|
||||
_hide() { this.dropdown.style.display = 'none'; this.ddIndex = -1; }
|
||||
|
||||
_onInput() {
|
||||
const q = this.input.value.trim().toLowerCase();
|
||||
if (!q) { this._hide(); return; }
|
||||
this._show(this.all.filter(v => v.toLowerCase().includes(q)));
|
||||
}
|
||||
|
||||
_onKeydown(e) {
|
||||
const opts = this.dropdown.querySelectorAll('.genre-option');
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.ddIndex = Math.min(this.ddIndex + 1, opts.length - 1);
|
||||
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.ddIndex = Math.max(this.ddIndex - 1, -1);
|
||||
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
|
||||
} else if (e.key === 'Enter' && this.ddIndex >= 0 && opts[this.ddIndex]) {
|
||||
e.preventDefault();
|
||||
this.input.value = opts[this.ddIndex].dataset.val;
|
||||
this._hide();
|
||||
} else if (e.key === 'Escape') {
|
||||
this._hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const authorSuggest = new TextSuggest('ed-author', 'author-dropdown');
|
||||
const publisherSuggest = new TextSuggest('ed-publisher', 'publisher-dropdown');
|
||||
const seriesSuggest = new TextSuggest('ed-series', 'series-dropdown');
|
||||
|
||||
// ── Edit panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function openEdit() {
|
||||
const [allGenres, allSubgenres, allTags] = await Promise.all([
|
||||
const [allGenres, allSubgenres, allTags, allAuthors, allPublishers, allSeries] = await Promise.all([
|
||||
fetch('/api/genres?type=genre').then(r => r.json()),
|
||||
fetch('/api/genres?type=subgenre').then(r => r.json()),
|
||||
fetch('/api/genres?type=tag').then(r => r.json()),
|
||||
fetch('/api/suggestions?type=author').then(r => r.json()),
|
||||
fetch('/api/suggestions?type=publisher').then(r => r.json()),
|
||||
fetch('/api/suggestions?type=series').then(r => r.json()),
|
||||
]);
|
||||
genreInput.setSuggestions(allGenres);
|
||||
subgenreInput.setSuggestions(allSubgenres);
|
||||
tagInput.setSuggestions(allTags);
|
||||
authorSuggest.setSuggestions(allAuthors);
|
||||
publisherSuggest.setSuggestions(allPublishers);
|
||||
seriesSuggest.setSuggestions(allSeries);
|
||||
|
||||
document.getElementById('ed-title').value = BOOK.title;
|
||||
document.getElementById('ed-author').value = BOOK.author;
|
||||
document.getElementById('ed-publisher').value = BOOK.publisher;
|
||||
document.getElementById('ed-series').value = BOOK.series;
|
||||
document.getElementById('ed-series-index').value = BOOK.series_index;
|
||||
document.getElementById('ed-status').value = BOOK.publication_status;
|
||||
document.getElementById('ed-series-index').value = BOOK.series_index + (BOOK.series_suffix || '');
|
||||
document.getElementById('ed-status').value = BOOK.publication_status || 'Complete';
|
||||
document.getElementById('ed-url').value = BOOK.source_url;
|
||||
document.getElementById('ed-publish-date').value = BOOK.publish_date;
|
||||
document.getElementById('ed-description').value = BOOK.description;
|
||||
|
||||
@ -551,6 +551,21 @@ html, body {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn.btn-bulk-delete {
|
||||
border: 1px solid rgba(200, 90, 58, 0.35);
|
||||
background: rgba(200, 90, 58, 0.14);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn.btn-bulk-delete:hover {
|
||||
background: rgba(200, 90, 58, 0.24);
|
||||
}
|
||||
|
||||
.btn.btn-bulk-delete:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.new-selection-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
@ -667,3 +682,114 @@ html, body {
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Bookmark cards ─────────────────────────────────────────────────────── */
|
||||
|
||||
.bm-card {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.bm-card-cover {
|
||||
flex-shrink: 0;
|
||||
width: 60px; height: 90px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.bm-card-cover img {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bm-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.bm-card-book {
|
||||
font-family: var(--serif);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bm-card-author {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.bm-card-chapter {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.bm-card-note {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.3rem;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bm-card-meta {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-faint);
|
||||
margin-top: auto;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.bm-card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
transition: color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-faint);
|
||||
}
|
||||
|
||||
.btn-small.btn-danger {
|
||||
color: var(--error);
|
||||
border-color: rgba(200,90,58,0.3);
|
||||
}
|
||||
|
||||
.btn-small.btn-danger:hover {
|
||||
background: rgba(200,90,58,0.1);
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
@ -36,6 +36,8 @@ let newSelectedFilenames = new Set();
|
||||
let newLastToggledIndex = null;
|
||||
let allViewMode = loadAllViewMode();
|
||||
let allVisibleColumns = loadAllVisibleColumns();
|
||||
let allSelectedFilenames = new Set();
|
||||
let allLastToggledIndex = null;
|
||||
|
||||
|
||||
// ── Placeholder cover generation ───────────────────────────────────────────
|
||||
@ -133,6 +135,9 @@ function updateCounts() {
|
||||
if (newEl) newEl.textContent = newCount || '';
|
||||
const archEl = document.getElementById('count-archived');
|
||||
if (archEl) archEl.textContent = archCount || '';
|
||||
const ratedCount = active.filter(b => b.rating > 0).length;
|
||||
const ratedEl = document.getElementById('count-rated');
|
||||
if (ratedEl) ratedEl.textContent = ratedCount || '';
|
||||
}
|
||||
|
||||
function _filenameBase(filename) {
|
||||
@ -177,6 +182,8 @@ function _viewUrl(view, param) {
|
||||
if (view === 'publishers') return '/library#publishers';
|
||||
if (view === 'publisher-detail') return '/library#publishers/' + encodeURIComponent(param || '');
|
||||
if (view === 'archived') return '/library#archived';
|
||||
if (view === 'bookmarks') return '/library#bookmarks';
|
||||
if (view === 'rated') return '/library#rated';
|
||||
if (view === 'new') return '/library#new';
|
||||
if (view === 'genre') return '/library#genre/' + encodeURIComponent(param || '');
|
||||
return '/library';
|
||||
@ -192,7 +199,7 @@ function _applyView(view, param) {
|
||||
if (si) { si.value = ''; document.getElementById('search-clear').style.display = 'none'; }
|
||||
}
|
||||
|
||||
['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived'].forEach(id => {
|
||||
['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.remove('active');
|
||||
});
|
||||
@ -203,6 +210,8 @@ function _applyView(view, param) {
|
||||
'publishers': 'nav-publishers', 'publisher-detail': 'nav-publishers',
|
||||
'new': 'nav-new',
|
||||
'archived': 'nav-archived',
|
||||
'bookmarks': 'nav-bookmarks',
|
||||
'rated': 'nav-rated',
|
||||
};
|
||||
const el = document.getElementById(activeMap[view]);
|
||||
if (el) el.classList.add('active');
|
||||
@ -218,6 +227,8 @@ function _applyView(view, param) {
|
||||
view === 'publisher-detail' ? publisherDisplayName(param || '') :
|
||||
view === 'new' ? 'New' :
|
||||
view === 'archived' ? 'Archived' :
|
||||
view === 'bookmarks' ? 'Bookmarks' :
|
||||
view === 'rated' ? 'Rated' :
|
||||
view === 'genre' ? `Genre: ${param || ''}` :
|
||||
view === 'search' ? `Search: "${param || ''}"` : '';
|
||||
|
||||
@ -225,6 +236,10 @@ function _applyView(view, param) {
|
||||
newSelectedFilenames.clear();
|
||||
newLastToggledIndex = null;
|
||||
}
|
||||
if (view !== 'all') {
|
||||
allSelectedFilenames.clear();
|
||||
allLastToggledIndex = null;
|
||||
}
|
||||
|
||||
const showBack = view === 'series-detail' || view === 'author-detail' || view === 'publisher-detail';
|
||||
document.getElementById('back-btn').style.display = showBack ? '' : 'none';
|
||||
@ -262,6 +277,8 @@ function renderGrid() {
|
||||
else if (currentView === 'new') renderNewBooksView(active.filter(b => b.needs_review));
|
||||
else if (currentView === 'genre') renderGenreView(currentParam);
|
||||
else if (currentView === 'search') renderSearchResults(currentParam);
|
||||
else if (currentView === 'bookmarks') renderBookmarksView();
|
||||
else if (currentView === 'rated') renderRatedView();
|
||||
}
|
||||
|
||||
// ── New view (bulk review + list/grid toggle) ─────────────────────────────
|
||||
@ -498,6 +515,22 @@ async function rateBook(filename, rating) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Returns the set of series names where at least one book has series_index > 0.
|
||||
// Used to decide whether to show [0] labels for index-0 books in indexed series.
|
||||
function indexedSeriesSet() {
|
||||
const set = new Set();
|
||||
for (const b of allBooks) {
|
||||
if (b.series && b.series_index > 0) set.add(b.series);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function seriesVolLabel(book, indexedSeries) {
|
||||
if (book.series_index > 0 || book.series_suffix) return String(book.series_index) + (book.series_suffix || '');
|
||||
if (book.series && indexedSeries.has(book.series)) return '0';
|
||||
return '';
|
||||
}
|
||||
|
||||
function newCellText(book, colId) {
|
||||
if (colId === 'publisher') return publisherDisplayName(bookPublisherKey(book));
|
||||
if (colId === 'author') return bookAuthor(book);
|
||||
@ -508,7 +541,7 @@ function newCellText(book, colId) {
|
||||
if (colId === 'genres') return bookGenres(book).join(', ');
|
||||
if (colId === 'subgenres') return bookSubgenres(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 seriesVolLabel(book, indexedSeriesSet());
|
||||
if (colId === 'status') return book.publication_status || '';
|
||||
if (colId === 'rating') return starsText(book.rating);
|
||||
return '';
|
||||
@ -663,6 +696,10 @@ function hideAllControls() {
|
||||
function setAllViewMode(mode) {
|
||||
if (mode !== 'grid' && mode !== 'list') return;
|
||||
allViewMode = mode;
|
||||
if (mode === 'grid') {
|
||||
allSelectedFilenames.clear();
|
||||
allLastToggledIndex = null;
|
||||
}
|
||||
persistAllViewMode();
|
||||
renderGrid();
|
||||
}
|
||||
@ -687,6 +724,14 @@ function toggleAllColumn(columnId) {
|
||||
function renderAllControls() {
|
||||
const controls = document.getElementById('all-controls');
|
||||
if (!controls) return;
|
||||
|
||||
const books = activeBooks();
|
||||
const listMode = allViewMode === 'list';
|
||||
const selectedCount = listMode
|
||||
? books.filter(b => allSelectedFilenames.has(b.filename)).length
|
||||
: 0;
|
||||
const allSelected = listMode && !!books.length && selectedCount === books.length;
|
||||
|
||||
controls.style.display = '';
|
||||
controls.innerHTML = `
|
||||
<div class="new-controls-bar">
|
||||
@ -694,7 +739,7 @@ function renderAllControls() {
|
||||
<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' ? `
|
||||
${listMode ? `
|
||||
<div class="new-actions">
|
||||
<button class="btn btn-light" onclick="toggleAllColumnsMenu(event)">Columns</button>
|
||||
<div class="new-columns-menu" id="all-columns-menu">
|
||||
@ -705,6 +750,12 @@ function renderAllControls() {
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
<span class="new-selection-count">${selectedCount} selected</span>
|
||||
<button class="btn btn-light" onclick="toggleSelectAllAllRows(${allSelected ? 'false' : 'true'}, activeBooks())">${allSelected ? 'Clear all' : 'Select all'}</button>
|
||||
<button class="btn btn-light" onclick="clearAllSelection()">Clear selection</button>
|
||||
<button class="btn btn-bulk-delete" id="btn-bulk-delete" onclick="deleteSelectedBooks()" ${selectedCount ? '' : 'disabled'}>
|
||||
Delete selected
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>`;
|
||||
@ -717,17 +768,22 @@ function renderAllBooksList(books) {
|
||||
return;
|
||||
}
|
||||
const cols = NEW_COLUMN_DEFS.filter(c => allVisibleColumns.includes(c.id));
|
||||
const selectedCount = books.filter(b => allSelectedFilenames.has(b.filename)).length;
|
||||
const allSelected = !!books.length && selectedCount === books.length;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="new-list-wrap">
|
||||
<table class="new-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="new-col-select"><input type="checkbox" ${allSelected ? 'checked' : ''} onchange="toggleSelectAllAllRows(this.checked, activeBooks())"/></th>
|
||||
${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)}">
|
||||
<td class="new-col-select"><input class="all-row-select" type="checkbox" ${allSelectedFilenames.has(b.filename) ? 'checked' : ''} onclick="handleAllRowCheckboxClick('${jsEsc(b.filename)}', this, event)"/></td>
|
||||
${cols.map(c => {
|
||||
const value = newCellText(b, c.id);
|
||||
if (c.id === 'title') return `<td class="col-title">${esc(value)}</td>`;
|
||||
@ -741,7 +797,8 @@ function renderAllBooksList(books) {
|
||||
</div>`;
|
||||
|
||||
container.querySelectorAll('.all-list-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
row.addEventListener('click', e => {
|
||||
if (e.target.type === 'checkbox') return;
|
||||
const filename = row.getAttribute('data-filename') || '';
|
||||
if (!filename) return;
|
||||
location.href = `/library/book/${encodeURIComponent(filename)}`;
|
||||
@ -758,16 +815,94 @@ function renderAllView(books) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAllAllRows(checked, books) {
|
||||
if (checked) {
|
||||
books.forEach(b => allSelectedFilenames.add(b.filename));
|
||||
allLastToggledIndex = books.length ? books.length - 1 : null;
|
||||
} else {
|
||||
books.forEach(b => allSelectedFilenames.delete(b.filename));
|
||||
allLastToggledIndex = null;
|
||||
}
|
||||
renderAllControls();
|
||||
const rowChecks = document.querySelectorAll('.all-row-select');
|
||||
rowChecks.forEach(cb => { cb.checked = checked; });
|
||||
}
|
||||
|
||||
function toggleAllRowWithShift(filename, checked, shiftPressed) {
|
||||
const books = activeBooks();
|
||||
const filenames = books.map(b => b.filename);
|
||||
const idx = filenames.indexOf(filename);
|
||||
if (idx === -1) return;
|
||||
|
||||
const doRange = !!(shiftPressed && allLastToggledIndex !== null);
|
||||
if (doRange) {
|
||||
const start = Math.min(allLastToggledIndex, idx);
|
||||
const end = Math.max(allLastToggledIndex, idx);
|
||||
for (let i = start; i <= end; i++) {
|
||||
const name = filenames[i];
|
||||
if (checked) allSelectedFilenames.add(name);
|
||||
else allSelectedFilenames.delete(name);
|
||||
}
|
||||
} else {
|
||||
if (checked) allSelectedFilenames.add(filename);
|
||||
else allSelectedFilenames.delete(filename);
|
||||
}
|
||||
|
||||
allLastToggledIndex = idx;
|
||||
renderAllControls();
|
||||
renderAllBooksList(books);
|
||||
}
|
||||
|
||||
function handleAllRowCheckboxClick(filename, checkboxEl, ev) {
|
||||
ev?.stopPropagation();
|
||||
toggleAllRowWithShift(filename, !!checkboxEl?.checked, !!(ev && ev.shiftKey));
|
||||
}
|
||||
|
||||
function clearAllSelection() {
|
||||
activeBooks().forEach(b => allSelectedFilenames.delete(b.filename));
|
||||
allLastToggledIndex = null;
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function deleteSelectedBooks() {
|
||||
const count = allSelectedFilenames.size;
|
||||
if (!count) return;
|
||||
document.getElementById('bulk-delete-count').textContent = count;
|
||||
document.getElementById('bulk-delete-overlay').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeBulkDeleteDialog() {
|
||||
document.getElementById('bulk-delete-overlay').classList.remove('visible');
|
||||
}
|
||||
|
||||
async function confirmBulkDelete() {
|
||||
const filenames = [...allSelectedFilenames];
|
||||
if (!filenames.length) return;
|
||||
const btn = document.getElementById('bulk-delete-btn');
|
||||
if (btn) btn.disabled = true;
|
||||
for (const filename of filenames) {
|
||||
try {
|
||||
await fetch(`/library/file/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
} catch {}
|
||||
}
|
||||
closeBulkDeleteDialog();
|
||||
allSelectedFilenames.clear();
|
||||
allLastToggledIndex = null;
|
||||
await loadLibrary();
|
||||
}
|
||||
|
||||
// ── Book grid (All / WTR / Author detail) ─────────────────────────────────
|
||||
|
||||
function renderBooksGrid(books) {
|
||||
const container = document.getElementById('grid-container');
|
||||
const idxSeries = indexedSeriesSet();
|
||||
|
||||
if (!books.length) {
|
||||
container.innerHTML = `<div class="empty">${
|
||||
currentView === 'wtr' ? 'No books marked as Want to Read. Star a book to add it here.' :
|
||||
currentView === 'archived' ? 'No archived books. Archive a book from its detail page.' :
|
||||
currentView === 'new' ? 'No newly imported books waiting for metadata review.' :
|
||||
currentView === 'rated' ? 'No rated books yet. Rate a book from its detail page.' :
|
||||
currentView === 'genre' ? `No books tagged "${esc(currentParam || '')}".` :
|
||||
currentView === 'search' ? `No results for "${esc(currentParam || '')}".` :
|
||||
'No books yet. Import EPUB, PDF or CBR/CBZ to get started.'
|
||||
@ -803,8 +938,9 @@ function renderBooksGrid(books) {
|
||||
}
|
||||
|
||||
const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star';
|
||||
const seriesVol = seriesVolLabel(b, idxSeries);
|
||||
const seriesText = b.series
|
||||
? `${esc(b.series)}${b.series_index ? ' <span class="series-index">[' + b.series_index + ']</span>' : ''}`
|
||||
? `${esc(b.series)}${seriesVol ? ' <span class="series-index">[' + esc(String(seriesVol)) + ']</span>' : ''}`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `
|
||||
@ -870,7 +1006,10 @@ function groupBySeries() {
|
||||
if (!map[b.series]) map[b.series] = [];
|
||||
map[b.series].push(b);
|
||||
}
|
||||
for (const s of Object.values(map)) s.sort((a, b) => a.series_index - b.series_index);
|
||||
for (const s of Object.values(map)) s.sort((a, b) => {
|
||||
if (a.series_index !== b.series_index) return a.series_index - b.series_index;
|
||||
return (a.series_suffix || '').localeCompare(b.series_suffix || '');
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
@ -963,10 +1102,19 @@ function renderSeriesGrid() {
|
||||
// ── Series detail ──────────────────────────────────────────────────────────
|
||||
|
||||
function getSeriesSlots(books) {
|
||||
const indexed = books.filter(b => b.series_index > 0);
|
||||
const unindexed = books.filter(b => b.series_index === 0 || !b.series_index);
|
||||
if (indexed.length === 0) return books;
|
||||
// Treat books as indexed (including index 0) only when at least one book
|
||||
// has series_index > 0 — this preserves the "unindexed flat list" behaviour
|
||||
// for series where no indices were ever assigned.
|
||||
const hasPositiveIndex = books.some(b => b.series_index > 0);
|
||||
if (!hasPositiveIndex) return books;
|
||||
|
||||
// Sort indexed books by (series_index, series_suffix) so 21 < 21a < 21b < 22.
|
||||
const indexed = [...books].sort((a, b) => {
|
||||
if (a.series_index !== b.series_index) return a.series_index - b.series_index;
|
||||
return (a.series_suffix || '').localeCompare(b.series_suffix || '');
|
||||
});
|
||||
|
||||
// Build slot map keyed by numeric index only (for gap detection).
|
||||
const byIndex = {};
|
||||
for (const b of indexed) {
|
||||
if (!byIndex[b.series_index]) byIndex[b.series_index] = [];
|
||||
@ -980,12 +1128,13 @@ function getSeriesSlots(books) {
|
||||
if (byIndex[i]) for (const b of byIndex[i]) slots.push(b);
|
||||
else slots.push({ missing: true, series_index: i });
|
||||
}
|
||||
return [...unindexed, ...slots];
|
||||
return slots;
|
||||
}
|
||||
|
||||
function renderSeriesDetail(seriesName) {
|
||||
const map = groupBySeries();
|
||||
const books = map[seriesName] || [];
|
||||
const hasPositiveIndex = books.some(b => b.series_index > 0);
|
||||
const slots = getSeriesSlots(books);
|
||||
const container = document.getElementById('grid-container');
|
||||
|
||||
@ -1001,10 +1150,10 @@ function renderSeriesDetail(seriesName) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'series-slot' + (slot.missing ? ' slot-missing' : '');
|
||||
|
||||
if (slot.series_index) {
|
||||
if (hasPositiveIndex || slot.series_index > 0 || slot.series_suffix) {
|
||||
const lbl = document.createElement('div');
|
||||
lbl.className = 'slot-index-label';
|
||||
lbl.textContent = `#${slot.series_index}`;
|
||||
lbl.textContent = `#${slot.series_index}${slot.series_suffix || ''}`;
|
||||
wrapper.appendChild(lbl);
|
||||
}
|
||||
|
||||
@ -1237,6 +1386,93 @@ function clearSearch() {
|
||||
switchView('all');
|
||||
}
|
||||
|
||||
// ── Bookmarks view ─────────────────────────────────────────────────────────
|
||||
|
||||
async function renderBookmarksView() {
|
||||
const container = document.getElementById('grid-container');
|
||||
container.innerHTML = '<div class="empty">Loading bookmarks…</div>';
|
||||
|
||||
let bookmarks;
|
||||
try {
|
||||
const resp = await fetch('/api/bookmarks');
|
||||
bookmarks = await resp.json();
|
||||
} catch {
|
||||
container.innerHTML = '<div class="empty">Failed to load bookmarks.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bookmarks.length) {
|
||||
container.innerHTML = '<div class="empty">No bookmarks yet. Tap the Bookmark button in the reader to save your place.</div>';
|
||||
const el = document.getElementById('count-bookmarks');
|
||||
if (el) el.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const el = document.getElementById('count-bookmarks');
|
||||
if (el) el.textContent = bookmarks.length || '';
|
||||
|
||||
container.innerHTML = bookmarks.map(bm => {
|
||||
const date = bm.created_at ? _fmtDate(bm.created_at) : '';
|
||||
const note = bm.note ? `<div class="bm-card-note">${esc(bm.note)}</div>` : '';
|
||||
const coverUrl = `/library/cover/${encodeURIComponent(bm.filename)}`;
|
||||
return `
|
||||
<div class="bm-card" id="bmc-${bm.id}">
|
||||
<a class="bm-card-cover" href="/library/read/${encodeURIComponent(bm.filename)}?bm_ch=${bm.chapter_index}&bm_scroll=${bm.scroll_frac.toFixed(4)}">
|
||||
<img src="${coverUrl}" onerror="this.style.display='none'" alt=""/>
|
||||
</a>
|
||||
<div class="bm-card-body">
|
||||
<div class="bm-card-book">${esc(bm.book_title)}</div>
|
||||
<div class="bm-card-author">${esc(bm.book_author)}</div>
|
||||
<div class="bm-card-chapter">${esc(bm.chapter_title)}</div>
|
||||
${note}
|
||||
<div class="bm-card-meta">${date}</div>
|
||||
<div class="bm-card-actions">
|
||||
<a class="bm-action-go btn-small" href="/library/read/${encodeURIComponent(bm.filename)}?bm_ch=${bm.chapter_index}&bm_scroll=${bm.scroll_frac.toFixed(4)}">
|
||||
Go to bookmark
|
||||
</a>
|
||||
<button class="bm-action-del btn-small btn-danger" onclick="deleteBookmark(${bm.id})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function deleteBookmark(id) {
|
||||
if (!confirm('Delete this bookmark?')) return;
|
||||
const resp = await fetch(`/library/bookmarks/${id}`, { method: 'DELETE' });
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
const card = document.getElementById(`bmc-${id}`);
|
||||
if (card) card.remove();
|
||||
// Update count
|
||||
const remaining = document.querySelectorAll('.bm-card').length;
|
||||
const el = document.getElementById('count-bookmarks');
|
||||
if (el) el.textContent = remaining || '';
|
||||
if (!remaining) {
|
||||
document.getElementById('grid-container').innerHTML =
|
||||
'<div class="empty">No bookmarks yet. Tap the Bookmark button in the reader to save your place.</div>';
|
||||
}
|
||||
} else {
|
||||
alert('Could not delete bookmark.');
|
||||
}
|
||||
}
|
||||
|
||||
function _fmtDate(isoStr) {
|
||||
try {
|
||||
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';
|
||||
return new Date(s).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
// ── Rated view ─────────────────────────────────────────────────────────────
|
||||
|
||||
function renderRatedView() {
|
||||
const books = activeBooks()
|
||||
.filter(b => b.rating > 0)
|
||||
.sort((a, b) => (b.rating - a.rating) || bookTitle(a).localeCompare(bookTitle(b)));
|
||||
renderBooksGrid(books);
|
||||
}
|
||||
|
||||
// ── Author detail ──────────────────────────────────────────────────────────
|
||||
|
||||
function renderAuthorDetail(authorName) {
|
||||
@ -1247,6 +1483,7 @@ function renderAuthorDetail(authorName) {
|
||||
const sb = b.series || '\uffff';
|
||||
if (sa !== sb) return sa.localeCompare(sb);
|
||||
if (a.series_index !== b.series_index) return a.series_index - b.series_index;
|
||||
if ((a.series_suffix || '') !== (b.series_suffix || '')) return (a.series_suffix || '').localeCompare(b.series_suffix || '');
|
||||
return bookTitle(a).localeCompare(bookTitle(b));
|
||||
});
|
||||
renderBooksGrid(books);
|
||||
@ -1261,6 +1498,7 @@ function renderPublisherDetail(publisherName) {
|
||||
const sb = b.series || '\uffff';
|
||||
if (sa !== sb) return sa.localeCompare(sb);
|
||||
if (a.series_index !== b.series_index) return a.series_index - b.series_index;
|
||||
if ((a.series_suffix || '') !== (b.series_suffix || '')) return (a.series_suffix || '').localeCompare(b.series_suffix || '');
|
||||
return bookTitle(a).localeCompare(bookTitle(b));
|
||||
});
|
||||
renderBooksGrid(books);
|
||||
@ -1464,7 +1702,7 @@ document.getElementById('search-input').addEventListener('input', function() {
|
||||
if (q) {
|
||||
currentView = 'search';
|
||||
currentParam = q;
|
||||
['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived'].forEach(id => {
|
||||
['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.remove('active');
|
||||
});
|
||||
@ -1524,6 +1762,8 @@ loadLibrary().then(() => {
|
||||
else if (hash.startsWith('publisher/')) { view = 'publisher-detail'; param = decodeURIComponent(hash.slice(10)); }
|
||||
else if (hash === 'archived') view = 'archived';
|
||||
else if (hash === 'new') view = 'new';
|
||||
else if (hash === 'bookmarks') view = 'bookmarks';
|
||||
else if (hash === 'rated') view = 'rated';
|
||||
else if (hash.startsWith('genre/')) { view = 'genre'; param = decodeURIComponent(hash.slice(6)); }
|
||||
history.replaceState({ view, param }, '', _viewUrl(view, param));
|
||||
_applyView(view, param);
|
||||
|
||||
@ -109,6 +109,43 @@ html {
|
||||
.btn-rescan:hover { background: var(--surface2); color: var(--text); }
|
||||
.btn-rescan:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.backup-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: background 0.12s, opacity 0.12s;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.backup-status-bar:hover { background: var(--surface2); opacity: 0.85; }
|
||||
|
||||
.backup-dot {
|
||||
flex-shrink: 0;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
}
|
||||
.backup-dot.dot-ok { background: var(--ok, #7fbe7f); }
|
||||
.backup-dot.dot-err { background: var(--err, #d0674c); }
|
||||
.backup-dot.dot-dim { background: var(--text-dim); opacity: 0.5; }
|
||||
.backup-dot.dot-running {
|
||||
background: var(--warn, #d2b063);
|
||||
animation: backup-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes backup-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.backup-status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ── Mobile hamburger ──────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-toggle {
|
||||
|
||||
@ -109,6 +109,26 @@
|
||||
<span class="sidebar-count" id="count-archived"></span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% if active == 'library' %}#{% else %}/library#bookmarks{% endif %}"
|
||||
{% if active == 'library' %}id="nav-bookmarks" onclick="switchView('bookmarks'); return false;"{% endif %}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Bookmarks
|
||||
<span class="sidebar-count" id="count-bookmarks"></span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% if active == 'library' %}#{% else %}/library#rated{% endif %}"
|
||||
{% if active == 'library' %}id="nav-rated" onclick="switchView('rated'); return false;"{% endif %}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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"/>
|
||||
</svg>
|
||||
Rated
|
||||
<span class="sidebar-count" id="count-rated"></span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/stats"{% if active == 'stats' %} class="active"{% endif %}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@ -171,6 +191,10 @@
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
<a href="/backup" class="backup-status-bar" id="backup-status-bar" title="Go to Backup">
|
||||
<span class="backup-dot" id="backup-dot"></span>
|
||||
<span class="backup-status-text" id="backup-status-text">Backup…</span>
|
||||
</a>
|
||||
<button class="btn-rescan" onclick="rescanLibraryGlobal()" id="rescan-btn">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
@ -203,6 +227,7 @@
|
||||
const authorCount = new Set(active.map(b => b.author).filter(Boolean)).size;
|
||||
const publisherCount = new Set(active.map(b => b.publisher).filter(Boolean)).size;
|
||||
const archivedCount = books.filter(b => b.archived).length;
|
||||
const ratedCount = active.filter(b => b.rating > 0).length;
|
||||
|
||||
const setCount = (id, value) => {
|
||||
const el = document.getElementById(id);
|
||||
@ -215,6 +240,7 @@
|
||||
setCount('count-series', seriesCount);
|
||||
setCount('count-authors', authorCount);
|
||||
setCount('count-publishers', publisherCount);
|
||||
setCount('count-rated', ratedCount);
|
||||
setCount('count-archived', archivedCount);
|
||||
}
|
||||
|
||||
@ -229,6 +255,79 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackupStatus() {
|
||||
const bar = document.getElementById('backup-status-bar');
|
||||
const dot = document.getElementById('backup-dot');
|
||||
const text = document.getElementById('backup-status-text');
|
||||
if (!bar) return;
|
||||
try {
|
||||
const r = await fetch('/api/backup/status');
|
||||
const d = await r.json();
|
||||
const s = d.status || 'never';
|
||||
dot.className = 'backup-dot';
|
||||
if (s === 'running') {
|
||||
dot.classList.add('dot-running');
|
||||
text.textContent = 'Backup running…';
|
||||
loadBackupProgress();
|
||||
return;
|
||||
} else if (s === 'success') {
|
||||
dot.classList.add('dot-ok');
|
||||
const ago = d.finished_at ? _backupAgo(d.finished_at) : '';
|
||||
text.textContent = 'Backup OK' + (ago ? ' · ' + ago : '');
|
||||
} else if (s === 'error') {
|
||||
dot.classList.add('dot-err');
|
||||
text.textContent = 'Backup failed';
|
||||
} else {
|
||||
dot.classList.add('dot-dim');
|
||||
text.textContent = 'No backup yet';
|
||||
}
|
||||
} catch (_) {
|
||||
const dot2 = document.getElementById('backup-dot');
|
||||
if (dot2) dot2.className = 'backup-dot dot-dim';
|
||||
if (text) text.textContent = 'Backup unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackupProgress() {
|
||||
const dot = document.getElementById('backup-dot');
|
||||
const text = document.getElementById('backup-status-text');
|
||||
if (!dot) return;
|
||||
try {
|
||||
const r = await fetch('/api/backup/progress');
|
||||
const d = await r.json();
|
||||
if (!d.running) {
|
||||
// Backup finished; reload full status
|
||||
await loadBackupStatus();
|
||||
return;
|
||||
}
|
||||
dot.className = 'backup-dot dot-running';
|
||||
const phase = d.phase || '';
|
||||
const phaseLbl = phase === 'scanning' ? 'scanning' :
|
||||
phase === 'snapshot' ? 'snapshot' :
|
||||
phase === 'pg_dump' ? 'pg_dump' : 'uploading';
|
||||
if (d.total > 0) {
|
||||
text.textContent = `${d.done} / ${d.total} · ${phaseLbl}`;
|
||||
} else {
|
||||
text.textContent = `Backup · ${phaseLbl}`;
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore; will retry
|
||||
}
|
||||
setTimeout(loadBackupProgress, 3000);
|
||||
}
|
||||
|
||||
function _backupAgo(isoStr) {
|
||||
try {
|
||||
// Ensure the string is parsed as UTC (append Z if no timezone info present)
|
||||
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';
|
||||
const diff = Math.floor((Date.now() - new Date(s).getTime()) / 1000);
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
async function rescanLibraryGlobal() {
|
||||
const btn = document.getElementById('rescan-btn');
|
||||
const label = document.getElementById('rescan-label');
|
||||
@ -246,5 +345,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBookmarkCount() {
|
||||
try {
|
||||
const resp = await fetch('/api/bookmarks');
|
||||
if (!resp.ok) return;
|
||||
const bms = await resp.json();
|
||||
const el = document.getElementById('count-bookmarks');
|
||||
if (el) el.textContent = bms.length || '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
refreshLibraryCounts();
|
||||
refreshBookmarkCount();
|
||||
loadBackupStatus();
|
||||
</script>
|
||||
|
||||
@ -135,8 +135,46 @@
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">Dropbox Settings</div>
|
||||
<label class="field-label" for="dropbox-token">Access Token</label>
|
||||
<input class="field-input" id="dropbox-token" type="password" placeholder="sl.B..." autocomplete="off"/>
|
||||
|
||||
<!-- Stap 1: App key + secret invullen en auth URL genereren -->
|
||||
<div id="oauth-step1">
|
||||
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
|
||||
Enter your App Key and App Secret from the
|
||||
<a href="https://www.dropbox.com/developers/apps" target="_blank" style="color:var(--accent);">Dropbox Developer Console</a>.
|
||||
Then click <strong>Generate Auth URL</strong> and follow the instructions.
|
||||
</p>
|
||||
<label class="field-label" for="app-key">App Key</label>
|
||||
<input class="field-input" id="app-key" type="text" placeholder="fyh6wd677d54ger"
|
||||
autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other"/>
|
||||
<label class="field-label" for="app-secret">App Secret</label>
|
||||
<input class="field-input" id="app-secret" type="password" placeholder="App secret"
|
||||
autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other"/>
|
||||
<div class="actions">
|
||||
<button class="btn primary" onclick="oauthPrepare()">Generate Auth URL</button>
|
||||
</div>
|
||||
<div class="status-line" id="oauth-step1-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- Stap 2: Auth URL tonen + code invoeren (initieel verborgen) -->
|
||||
<div id="oauth-step2" style="display:none;margin-top:1rem;border-top:1px solid var(--border);padding-top:1rem;">
|
||||
<p class="muted" style="margin-top:0;margin-bottom:0.5rem;">
|
||||
Open the URL below in your browser, log in to Dropbox and approve the app.
|
||||
Dropbox will then show a code — paste it below.
|
||||
</p>
|
||||
<div style="margin-bottom:0.7rem;">
|
||||
<a id="oauth-auth-url" href="#" target="_blank" style="color:var(--accent);font-family:var(--mono);font-size:0.75rem;word-break:break-all;"></a>
|
||||
</div>
|
||||
<label class="field-label" for="oauth-code">Authorization Code</label>
|
||||
<input class="field-input" id="oauth-code" type="text" placeholder="Paste the code from Dropbox here" autocomplete="off"/>
|
||||
<div class="actions">
|
||||
<button class="btn primary" onclick="oauthExchange()">Save & Activate</button>
|
||||
<button class="btn" onclick="oauthReset()">Cancel</button>
|
||||
</div>
|
||||
<div class="status-line" id="oauth-step2-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- Overige instellingen -->
|
||||
<div style="margin-top:1rem;border-top:1px solid var(--border);padding-top:1rem;">
|
||||
<label class="field-label" for="dropbox-root">Dropbox Root Path</label>
|
||||
<input class="field-input" id="dropbox-root" type="text" placeholder="/novela" autocomplete="off"/>
|
||||
<label class="field-label" for="retention-count">Snapshots To Keep</label>
|
||||
@ -149,11 +187,11 @@
|
||||
<label class="field-label" for="schedule-hours">Schedule Interval (hours)</label>
|
||||
<input class="field-input" id="schedule-hours" type="number" min="1" step="1" value="24" autocomplete="off"/>
|
||||
<div class="actions">
|
||||
<button class="btn primary" onclick="saveDropboxToken()">Save Token</button>
|
||||
<button class="btn" onclick="toggleDropboxToken()">Show / Hide</button>
|
||||
<button class="btn primary" onclick="saveDropboxToken()">Save Settings</button>
|
||||
<button class="btn" onclick="clearDropboxToken()">Remove Token</button>
|
||||
</div>
|
||||
<div class="status-line" id="dropbox-status"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@ -277,7 +315,6 @@
|
||||
|
||||
async function loadDropboxSettings() {
|
||||
const out = document.getElementById('dropbox-status');
|
||||
const tokenEl = document.getElementById('dropbox-token');
|
||||
const rootEl = document.getElementById('dropbox-root');
|
||||
const retentionEl = document.getElementById('retention-count');
|
||||
const scheduleEnabledEl = document.getElementById('schedule-enabled');
|
||||
@ -287,14 +324,14 @@
|
||||
try {
|
||||
const r = await fetch('/api/backup/credentials');
|
||||
const d = await r.json();
|
||||
tokenEl.value = '';
|
||||
rootEl.value = d.dropbox_root || '/novela';
|
||||
retentionEl.value = d.retention_count ?? 14;
|
||||
scheduleEnabledEl.value = String(!!d.schedule_enabled);
|
||||
scheduleHoursEl.value = d.schedule_interval_hours ?? 24;
|
||||
if (d.configured) {
|
||||
out.className = 'status-line ok';
|
||||
out.textContent = `Configured (${d.token_preview || 'token set'})${d.updated_at ? ` • updated ${d.updated_at}` : ''}`;
|
||||
const mode = d.app_key_configured ? ' • refresh token' : ' • legacy token';
|
||||
out.textContent = `Configured (${d.token_preview || 'token set'})${mode}${d.updated_at ? ` • updated ${d.updated_at}` : ''}`;
|
||||
} else {
|
||||
out.className = 'status-line warn';
|
||||
out.textContent = 'No Dropbox token configured.';
|
||||
@ -305,9 +342,75 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function oauthPrepare() {
|
||||
const out = document.getElementById('oauth-step1-status');
|
||||
const appKey = (document.getElementById('app-key').value || '').trim();
|
||||
const appSecret = (document.getElementById('app-secret').value || '').trim();
|
||||
if (!appKey || !appSecret) {
|
||||
out.className = 'status-line err';
|
||||
out.textContent = 'Fill in both App Key and App Secret.';
|
||||
return;
|
||||
}
|
||||
out.className = 'status-line warn';
|
||||
out.textContent = 'Generating auth URL...';
|
||||
try {
|
||||
const r = await fetch('/api/backup/oauth/prepare', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({app_key: appKey, app_secret: appSecret}),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.error || 'prepare failed');
|
||||
document.getElementById('oauth-auth-url').href = d.auth_url;
|
||||
document.getElementById('oauth-auth-url').textContent = d.auth_url;
|
||||
document.getElementById('oauth-step2').style.display = '';
|
||||
out.className = 'status-line ok';
|
||||
out.textContent = 'Auth URL generated. Open the link above and paste the code below.';
|
||||
} catch (e) {
|
||||
out.className = 'status-line err';
|
||||
out.textContent = `Failed: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function oauthExchange() {
|
||||
const out = document.getElementById('oauth-step2-status');
|
||||
const code = (document.getElementById('oauth-code').value || '').trim();
|
||||
if (!code) {
|
||||
out.className = 'status-line err';
|
||||
out.textContent = 'Paste the authorization code from Dropbox first.';
|
||||
return;
|
||||
}
|
||||
out.className = 'status-line warn';
|
||||
out.textContent = 'Exchanging code for refresh token...';
|
||||
try {
|
||||
const r = await fetch('/api/backup/oauth/exchange', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({code}),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.error || 'exchange failed');
|
||||
out.className = 'status-line ok';
|
||||
out.textContent = d.message || 'Dropbox connected successfully.';
|
||||
document.getElementById('oauth-code').value = '';
|
||||
document.getElementById('oauth-step2').style.display = 'none';
|
||||
document.getElementById('oauth-step1-status').textContent = '';
|
||||
await Promise.all([loadDropboxSettings(), loadHealth()]);
|
||||
} catch (e) {
|
||||
out.className = 'status-line err';
|
||||
out.textContent = `Failed: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
function oauthReset() {
|
||||
document.getElementById('oauth-step2').style.display = 'none';
|
||||
document.getElementById('oauth-code').value = '';
|
||||
document.getElementById('oauth-step2-status').textContent = '';
|
||||
document.getElementById('oauth-step1-status').textContent = '';
|
||||
}
|
||||
|
||||
async function saveDropboxToken() {
|
||||
const out = document.getElementById('dropbox-status');
|
||||
const token = (document.getElementById('dropbox-token').value || '').trim();
|
||||
const dropboxRoot = (document.getElementById('dropbox-root').value || '').trim();
|
||||
const retentionCount = Math.max(1, parseInt((document.getElementById('retention-count').value || '14').trim(), 10) || 14);
|
||||
const scheduleEnabled = document.getElementById('schedule-enabled').value === 'true';
|
||||
@ -319,23 +422,20 @@
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
dropbox_root: dropboxRoot,
|
||||
retention_count: retentionCount,
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_interval_hours: scheduleIntervalHours
|
||||
schedule_interval_hours: scheduleIntervalHours,
|
||||
}),
|
||||
});
|
||||
const raw = await r.text();
|
||||
let d;
|
||||
try {
|
||||
d = JSON.parse(raw);
|
||||
} catch (_) {
|
||||
try { d = JSON.parse(raw); } catch (_) {
|
||||
throw new Error(`HTTP ${r.status}: ${raw.slice(0, 180) || 'non-JSON response'}`);
|
||||
}
|
||||
if (!d.ok) throw new Error(d.error || 'save failed');
|
||||
out.className = 'status-line ok';
|
||||
out.textContent = `Backup settings saved. Root: ${d.dropbox_root || dropboxRoot || '/novela'} • keep: ${d.retention_count || retentionCount} • schedule: ${(d.schedule_enabled ? 'on' : 'off')} (${d.schedule_interval_hours || scheduleIntervalHours}h)`;
|
||||
out.textContent = `Settings saved. Root: ${d.dropbox_root || dropboxRoot || '/novela'} • keep: ${d.retention_count || retentionCount} • schedule: ${d.schedule_enabled ? 'on' : 'off'} (${d.schedule_interval_hours || scheduleIntervalHours}h)`;
|
||||
await Promise.all([loadDropboxSettings(), loadHealth()]);
|
||||
} catch (e) {
|
||||
out.className = 'status-line err';
|
||||
@ -352,7 +452,8 @@
|
||||
await fetch('/api/backup/credentials', {method: 'DELETE'});
|
||||
out.className = 'status-line ok';
|
||||
out.textContent = 'Dropbox token removed.';
|
||||
document.getElementById('dropbox-token').value = '';
|
||||
document.getElementById('app-key').value = '';
|
||||
document.getElementById('app-secret').value = '';
|
||||
document.getElementById('dropbox-root').value = '/novela';
|
||||
document.getElementById('retention-count').value = 14;
|
||||
document.getElementById('schedule-enabled').value = 'false';
|
||||
@ -364,11 +465,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDropboxToken() {
|
||||
const el = document.getElementById('dropbox-token');
|
||||
el.type = el.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
|
||||
async function runBackup(dryRun) {
|
||||
const btnDry = document.getElementById('btn-dry');
|
||||
const btnLive = document.getElementById('btn-live');
|
||||
@ -390,6 +486,8 @@
|
||||
out.className = 'status-line ok';
|
||||
if (d.status === 'running') {
|
||||
out.textContent = `Backup started in background. id=${d.backup_id}, dry_run=${d.dry_run}`;
|
||||
// Immediately kick off sidebar progress polling
|
||||
if (typeof loadBackupProgress === 'function') loadBackupProgress();
|
||||
} else {
|
||||
out.textContent = `Backup ${d.status}. id=${d.backup_id}, files=${d.files_count}, bytes=${d.size_bytes}, dry_run=${d.dry_run}`;
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
{% if series %}
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Series</span>
|
||||
<span class="meta-value">{{ series }}{% if series_index %} [{{ series_index }}]{% endif %}</span>
|
||||
<span class="meta-value">{{ series }}{% if series_index is defined and (series_index or series_suffix or series_is_indexed) %} [{{ series_index }}{{ series_suffix }}]{% endif %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="meta-row">
|
||||
@ -222,11 +222,29 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="edit-field"><label class="edit-label">Title</label><input class="edit-input" id="ed-title" type="text"/></div>
|
||||
<div class="edit-field"><label class="edit-label">Author</label><input class="edit-input" id="ed-author" type="text"/></div>
|
||||
<div class="edit-field"><label class="edit-label">Publisher</label><input class="edit-input" id="ed-publisher" type="text"/></div>
|
||||
<div class="edit-field">
|
||||
<label class="edit-label">Author</label>
|
||||
<div class="genre-wrap">
|
||||
<input class="edit-input" id="ed-author" type="text" autocomplete="off"/>
|
||||
<div class="genre-dropdown" id="author-dropdown" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-field">
|
||||
<label class="edit-label">Publisher</label>
|
||||
<div class="genre-wrap">
|
||||
<input class="edit-input" id="ed-publisher" type="text" autocomplete="off"/>
|
||||
<div class="genre-dropdown" id="publisher-dropdown" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<div class="edit-field"><label class="edit-label">Series</label><input class="edit-input" id="ed-series" type="text"/></div>
|
||||
<div class="edit-field"><label class="edit-label">Volume</label><input class="edit-input" id="ed-series-index" type="number" min="0"/></div>
|
||||
<div class="edit-field">
|
||||
<label class="edit-label">Series</label>
|
||||
<div class="genre-wrap">
|
||||
<input class="edit-input" id="ed-series" type="text" autocomplete="off"/>
|
||||
<div class="genre-dropdown" id="series-dropdown" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-field"><label class="edit-label">Volume</label><input class="edit-input" id="ed-series-index" type="text" placeholder="e.g. 1 or 21a"/></div>
|
||||
</div>
|
||||
<div class="edit-field">
|
||||
<label class="edit-label">Status</label>
|
||||
@ -313,6 +331,7 @@
|
||||
publisher: {{ (publisher or '') | tojson }},
|
||||
series: {{ (series or '') | tojson }},
|
||||
series_index: {{ series_index or 0 }},
|
||||
series_suffix: {{ (series_suffix or '') | tojson }},
|
||||
publication_status: {{ (publication_status or '') | tojson }},
|
||||
source_url: {{ (source_url or '') | tojson }},
|
||||
publish_date: {{ (publish_date or '') | tojson }},
|
||||
|
||||
@ -55,6 +55,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk delete dialog -->
|
||||
<div class="overlay" id="bulk-delete-overlay">
|
||||
<div class="dialog">
|
||||
<div class="dialog-title del">Delete books</div>
|
||||
<p>Delete <strong id="bulk-delete-count"></strong> selected book(s)?<br/>Files will be permanently removed from disk. This cannot be undone.</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-cancel" onclick="closeBulkDeleteDialog()">Cancel</button>
|
||||
<button class="btn btn-confirm-del" id="bulk-delete-btn" onclick="confirmBulkDelete()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add cover dialog -->
|
||||
<div class="overlay" id="cover-overlay">
|
||||
<div class="dialog">
|
||||
|
||||
@ -67,6 +67,58 @@
|
||||
.btn-header:hover { color: var(--text); border-color: var(--text-faint); }
|
||||
.btn-header-read { color: var(--success); border-color: rgba(107,170,107,0.3); }
|
||||
.btn-header-read:hover { background: rgba(107,170,107,0.08); border-color: var(--success); }
|
||||
.btn-header-bm { color: var(--accent); border-color: rgba(200,120,58,0.3); }
|
||||
.btn-header-bm:hover { background: rgba(200,120,58,0.08); border-color: var(--accent); }
|
||||
|
||||
/* ── Bookmark modal ── */
|
||||
.bm-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.55); z-index: 300;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.bm-overlay.open { display: flex; }
|
||||
.bm-modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.4rem 1.5rem;
|
||||
width: min(420px, 92vw);
|
||||
}
|
||||
.bm-title {
|
||||
font-family: var(--mono); font-size: 0.7rem;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--accent); margin-bottom: 1rem;
|
||||
}
|
||||
.bm-chapter {
|
||||
font-family: var(--mono); font-size: 0.72rem;
|
||||
color: var(--text-dim); margin-bottom: 1rem;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.bm-label {
|
||||
font-family: var(--mono); font-size: 0.7rem;
|
||||
color: var(--text-dim); margin-bottom: 0.4rem; display: block;
|
||||
}
|
||||
.bm-textarea {
|
||||
width: 100%; min-height: 90px;
|
||||
background: var(--surface2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--mono); font-size: 0.78rem;
|
||||
color: var(--text); padding: 0.55rem 0.75rem;
|
||||
resize: vertical; line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bm-textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.bm-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
|
||||
.bm-btn {
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--mono); font-size: 0.72rem;
|
||||
cursor: pointer; border: 1px solid var(--border);
|
||||
}
|
||||
.bm-btn-cancel { background: none; color: var(--text-dim); }
|
||||
.bm-btn-cancel:hover { border-color: var(--text-faint); color: var(--text); }
|
||||
.bm-btn-save { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.bm-btn-save:hover { filter: brightness(1.1); }
|
||||
|
||||
/* ── Settings drawer ── */
|
||||
.settings-overlay {
|
||||
@ -226,6 +278,20 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Bookmark modal -->
|
||||
<div class="bm-overlay" id="bm-overlay">
|
||||
<div class="bm-modal">
|
||||
<div class="bm-title">Add bookmark</div>
|
||||
<div class="bm-chapter" id="bm-chapter"></div>
|
||||
<label class="bm-label" for="bm-note">Note (optional)</label>
|
||||
<textarea class="bm-textarea" id="bm-note" placeholder="What do you want to remember here?"></textarea>
|
||||
<div class="bm-actions">
|
||||
<button class="bm-btn bm-btn-cancel" onclick="closeBookmarkModal()">Cancel</button>
|
||||
<button class="bm-btn bm-btn-save" onclick="saveBookmark()">Save bookmark</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
@ -273,6 +339,12 @@
|
||||
</a>
|
||||
<div class="header-title" id="header-title"></div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-header btn-header-bm" onclick="openBookmarkModal()" title="Add bookmark at current position">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Bookmark
|
||||
</button>
|
||||
<button class="btn-header btn-header-read" onclick="markRead()">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
@ -358,18 +430,20 @@
|
||||
document.getElementById('settings-overlay').classList.remove('open');
|
||||
}
|
||||
|
||||
const IS_PAGED = (FORMAT === 'pdf' || FORMAT === 'cbr' || FORMAT === 'cbz');
|
||||
|
||||
// ── Progress ───────────────────────────────────────────────────
|
||||
function calcProgress() {
|
||||
const total = chapters.length;
|
||||
if (FORMAT === 'pdf') {
|
||||
const pct = total > 1 ? Math.round((currentIndex / (total - 1)) * 100) : 0;
|
||||
if (IS_PAGED) {
|
||||
const pct = total > 0 ? Math.round(((currentIndex + 1) / total) * 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
|
||||
? Math.round(((currentIndex + scrollFrac) / (total - 1)) * 100)
|
||||
: total === 1 ? Math.round(scrollFrac * 100) : 0;
|
||||
const pct = total > 0
|
||||
? Math.min(100, Math.round(((currentIndex + scrollFrac) / total) * 100))
|
||||
: 0;
|
||||
return { scrollFrac, pct };
|
||||
}
|
||||
|
||||
@ -418,6 +492,30 @@
|
||||
if (saveProgress) scheduleSave();
|
||||
}
|
||||
|
||||
// ── CBR/CBZ page loading ───────────────────────────────────────
|
||||
async function loadCbrPage(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/cbr/${encodeURIComponent(filename)}?page=${index}"` +
|
||||
` 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) {
|
||||
if (index < 0 || index >= chapters.length) return;
|
||||
@ -454,6 +552,8 @@
|
||||
function navigate(delta) {
|
||||
if (FORMAT === 'pdf') {
|
||||
loadPdfPage(currentIndex + delta, true);
|
||||
} else if (FORMAT === 'cbr' || FORMAT === 'cbz') {
|
||||
loadCbrPage(currentIndex + delta, true);
|
||||
} else {
|
||||
loadChapter(currentIndex + delta, true, 0);
|
||||
}
|
||||
@ -462,7 +562,7 @@
|
||||
// ── Scroll tracking (EPUB only) ────────────────────────────────
|
||||
window.addEventListener('scroll', () => {
|
||||
updateFooter();
|
||||
if (FORMAT !== 'pdf') {
|
||||
if (!IS_PAGED) {
|
||||
clearTimeout(scrollTimer);
|
||||
scrollTimer = setTimeout(scheduleSave, 300);
|
||||
}
|
||||
@ -472,7 +572,7 @@
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); navigate(1); }
|
||||
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); navigate(-1); }
|
||||
if (e.key === 'Escape') closeSettings();
|
||||
if (e.key === 'Escape') { closeSettings(); closeBookmarkModal(); }
|
||||
});
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────
|
||||
@ -483,9 +583,16 @@
|
||||
const progResp = await fetch(`/library/progress/${encodeURIComponent(filename)}`);
|
||||
const prog = await progResp.json();
|
||||
|
||||
// Bookmark navigation takes priority over saved progress
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const bmCh = urlParams.get('bm_ch');
|
||||
const bmScr = urlParams.get('bm_scroll');
|
||||
let startIndex = 0;
|
||||
let startScroll = 0;
|
||||
if (prog.cfi) {
|
||||
if (bmCh !== null) {
|
||||
startIndex = Math.max(0, parseInt(bmCh, 10) || 0);
|
||||
startScroll = parseFloat(bmScr) || 0;
|
||||
} else if (prog.cfi) {
|
||||
const parts = prog.cfi.split(':');
|
||||
const idx = parseInt(parts[0], 10);
|
||||
if (!isNaN(idx) && idx >= 0) {
|
||||
@ -501,6 +608,13 @@
|
||||
chapters = Array.from({ length: pageCount }, (_, i) => ({ index: i, title: `Page ${i + 1}` }));
|
||||
if (startIndex >= chapters.length) startIndex = 0;
|
||||
await loadPdfPage(startIndex, false);
|
||||
} else if (FORMAT === 'cbr' || FORMAT === 'cbz') {
|
||||
const infoResp = await fetch(`/api/cbr/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 loadCbrPage(startIndex, false);
|
||||
} else {
|
||||
const chapResp = await fetch(`/library/chapters/${encodeURIComponent(filename)}`);
|
||||
chapters = await chapResp.json();
|
||||
@ -517,6 +631,44 @@
|
||||
window.location.href = `/library/book/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
// ── Bookmarks ──────────────────────────────────────────────────
|
||||
function openBookmarkModal() {
|
||||
const chTitle = IS_PAGED
|
||||
? `Page ${currentIndex + 1}`
|
||||
: (chapters[currentIndex]?.title || `Chapter ${currentIndex + 1}`);
|
||||
document.getElementById('bm-chapter').textContent = chTitle;
|
||||
document.getElementById('bm-note').value = '';
|
||||
document.getElementById('bm-overlay').classList.add('open');
|
||||
setTimeout(() => document.getElementById('bm-note').focus(), 50);
|
||||
}
|
||||
|
||||
function closeBookmarkModal() {
|
||||
document.getElementById('bm-overlay').classList.remove('open');
|
||||
}
|
||||
|
||||
async function saveBookmark() {
|
||||
const { scrollFrac } = calcProgress();
|
||||
const chTitle = IS_PAGED
|
||||
? `Page ${currentIndex + 1}`
|
||||
: (chapters[currentIndex]?.title || `Chapter ${currentIndex + 1}`);
|
||||
const note = document.getElementById('bm-note').value.trim();
|
||||
closeBookmarkModal();
|
||||
await fetch(`/library/bookmarks/${encodeURIComponent(filename)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chapter_index: currentIndex,
|
||||
scroll_frac: scrollFrac,
|
||||
chapter_title: chTitle,
|
||||
note,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('bm-overlay').addEventListener('click', (e) => {
|
||||
if (e.target === e.currentTarget) closeBookmarkModal();
|
||||
});
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
@ -103,7 +103,7 @@ Home read sections are ordered oldest-first:
|
||||
- `GET /library/epub/{filename}` — serve EPUB inline (no attachment header)
|
||||
- `GET /library/chapters/{filename}` — EPUB spine as JSON
|
||||
- `GET /library/chapter/{index}/{filename}` — single EPUB chapter as HTML fragment
|
||||
- `GET /library/chapter-img/{path}?filename=…` — image extracted from EPUB ZIP
|
||||
- `GET /library/chapter-img/{path}?filename=…` — image extracted from EPUB ZIP; `path` is the full internal ZIP path (e.g. `OEBPS/Images/cover.jpg` or `EPUB/images/cover.jpg`); case-insensitive fallback for mismatched folder names
|
||||
- `GET /library/pdf/{filename}?page=N&dpi=150` — render PDF page as PNG
|
||||
- `GET /api/pdf/info/{filename}` — `{"page_count": N}`
|
||||
- `GET /library/cbr/{filename}/{page}` — CBR/CBZ page as image
|
||||
@ -115,7 +115,12 @@ Home read sections are ordered oldest-first:
|
||||
- `GET /api/genres` — all tags from `book_tags` (optional `?type=genre|subgenre|tag`)
|
||||
- `PATCH /library/book/{filename}` — update metadata + tags; moves file if path fields change; DB-only for non-EPUB
|
||||
- `POST /library/rating/{filename}` — set/clear 1–5 star rating; writes to EPUB OPF / CBZ ComicInfo.xml; DB-only for CBR/PDF
|
||||
- `GET /library/read/{filename}` — reader page (EPUB or PDF)
|
||||
- `GET /library/read/{filename}` — reader page (EPUB or PDF); supports `?bm_ch=N&bm_scroll=F` to jump to bookmark position
|
||||
- `GET /library/bookmarks/{filename}` — list bookmarks for a book
|
||||
- `POST /library/bookmarks/{filename}` — add bookmark `{chapter_index, scroll_frac, chapter_title, note}`
|
||||
- `PATCH /library/bookmarks/{id}` — update bookmark note
|
||||
- `DELETE /library/bookmarks/{id}` — delete bookmark
|
||||
- `GET /api/bookmarks` — all bookmarks across all books (includes `book_title`, `book_author`)
|
||||
|
||||
### `routers/editor.py`
|
||||
- `GET /library/editor/{filename}` — EPUB chapter editor page
|
||||
@ -147,22 +152,36 @@ Home read sections are ordered oldest-first:
|
||||
|
||||
### `routers/backup.py`
|
||||
- `GET /backup` — backup page
|
||||
- `GET /POST /DELETE /api/backup/credentials` — Dropbox settings
|
||||
- `GET /api/backup/health` — Dropbox connectivity check
|
||||
- `GET /api/backup/credentials` — Dropbox settings (includes `app_key_configured` flag)
|
||||
- `POST /api/backup/credentials` — save Dropbox settings
|
||||
- `DELETE /api/backup/credentials` — remove all Dropbox credentials
|
||||
- `POST /api/backup/oauth/prepare` — save app key + secret, return Dropbox auth URL
|
||||
- `POST /api/backup/oauth/exchange` — exchange authorization code for refresh token
|
||||
- `GET /api/backup/health` — Dropbox connectivity check (includes `schedule_enabled`, `schedule_interval_hours`)
|
||||
- `GET /api/backup/status` — current backup status
|
||||
- `GET /api/backup/history` — backup run history
|
||||
- `GET /api/backup/history` — backup run history (last 20)
|
||||
- `GET /api/backup/progress` — live progress of running backup `{running, done, total, phase}`
|
||||
- `POST /api/backup/run` — trigger backup (background task)
|
||||
|
||||
---
|
||||
|
||||
## Backup & Security
|
||||
- Dropbox token is stored encrypted-at-rest in `credentials` (`site='dropbox'`).
|
||||
- Dropbox backup root is stored encrypted in `credentials` (`site='dropbox_backup_root'`).
|
||||
- Retention (`snapshots to keep`) is stored encrypted in `credentials` (`site='dropbox_backup_retention'`).
|
||||
- Backup schedule (`enabled` + `interval_hours`) is stored encrypted in `credentials` (`site='dropbox_backup_schedule'`).
|
||||
- Dropbox token (refresh token or legacy access token) stored encrypted in `credentials` (`site='dropbox'`).
|
||||
- Dropbox app key stored encrypted in `credentials` (`site='dropbox_app_key'`).
|
||||
- Dropbox app secret stored encrypted in `credentials` (`site='dropbox_app_secret'`).
|
||||
- Dropbox backup root stored encrypted in `credentials` (`site='dropbox_backup_root'`).
|
||||
- Retention (`snapshots to keep`) stored encrypted in `credentials` (`site='dropbox_backup_retention'`).
|
||||
- Backup schedule (`enabled` + `interval_hours`) stored encrypted in `credentials` (`site='dropbox_backup_schedule'`).
|
||||
- Encryption uses `NOVELA_MASTER_KEY` (Fernet).
|
||||
|
||||
Implementation details:
|
||||
### Dropbox authentication
|
||||
- Preferred: OAuth2 refresh token (does not expire). Set up via the two-step flow on `/backup`:
|
||||
1. Enter App Key + App Secret → click **Generate Auth URL**
|
||||
2. Approve in browser → paste the code → click **Save & Activate**
|
||||
- `_dbx()` uses `oauth2_refresh_token` + `app_key` + `app_secret` for automatic token renewal.
|
||||
- Fallback: legacy short-lived access token (backwards compatible; works without app key/secret).
|
||||
|
||||
### Implementation details
|
||||
- Versioned backups with deduplication:
|
||||
- file objects in Dropbox: `library_objects/{sha256_prefix}/{sha256}`
|
||||
- snapshots in Dropbox: `library_snapshots/snapshot-YYYYMMDD-HHMMSS.json`
|
||||
@ -172,6 +191,7 @@ Implementation details:
|
||||
- Local manifest cache (`config/backup_manifest.json`) speeds up change detection.
|
||||
- Database backup is done via `pg_dump` to Dropbox `postgres/`.
|
||||
- `POST /api/backup/run` always starts a background task and returns immediately.
|
||||
- `GET /api/backup/progress` returns in-memory progress updated per file; phases: `starting` → `scanning` → `uploading` → `snapshot` → `pg_dump`.
|
||||
- Scheduler runs in the background (`start_backup_scheduler`) and triggers on interval when enabled.
|
||||
- Concurrency guard: only one backup can run at a time.
|
||||
- After container restart/crash, stale `running` logs are auto-marked as interrupted/error.
|
||||
@ -200,9 +220,11 @@ Dropbox settings are managed via the web UI on `/backup`.
|
||||
- `List` mode has a column visibility filter: Publisher, Author, Series, Volume, Title, Has cover, Updated, Genres, Sub-genres, Tags, Status.
|
||||
- `List` mode supports multi-select with `Shift+click` range selection on checkboxes.
|
||||
- `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).
|
||||
- `All books` view supports `Grid` and `List` mode (same columns as `New`).
|
||||
- View mode persisted in `localStorage` as `novela.all.viewMode`.
|
||||
- Column visibility persisted in `localStorage` as `novela.all.visibleColumns`.
|
||||
- `List` mode has a checkbox column, column visibility filter, and multi-select with `Shift+click` range selection.
|
||||
- `List` mode has a `Delete selected` bulk action: confirms then calls `DELETE /library/file/{filename}` for each selected book.
|
||||
- Star ratings (1–5) shown under the cover in all grid views:
|
||||
- Display-only in grid cards (no click, prevents accidental taps while scrolling).
|
||||
- Interactive in Book Detail (1.1rem, clickable; clicking the active star clears the rating).
|
||||
@ -212,11 +234,12 @@ Dropbox settings are managed via the web UI on `/backup`.
|
||||
- Text colour: 5 warm-tone presets `#e8e2d9` → `#938d86`, persisted as `reader-text-colour`.
|
||||
- Hamburger and back-link separated with `margin-left: 1rem` on `.header-back`.
|
||||
- Reader supports EPUB and PDF:
|
||||
- EPUB: chapter-text rendering; progress = `{chapterIndex}:{scrollFrac}`.
|
||||
- EPUB: chapter-text rendering; progress = `{chapterIndex}:{scrollFrac}`; progress % = `(chapterIndex + scrollFrac) / total * 100`.
|
||||
- PDF: page-image rendering via `/library/pdf/{filename}?page=N`; page count from `/api/pdf/info/{filename}`; progress = `{pageIndex}:0`; keyboard/button navigation identical.
|
||||
- `reader.html` branches on `FORMAT` variable injected by the server.
|
||||
- `Edit EPUB` button in Book Detail is only shown for `.epub` files.
|
||||
- Backup page supports: manual run, dry-run, Dropbox root, retention count, schedule (on/off + hours), status + history.
|
||||
- Bookmarks: saved per book via `POST /library/bookmarks/{filename}`; shown in Library sidebar section; navigated via `?bm_ch=N&bm_scroll=F` URL params on reader page.
|
||||
|
||||
---
|
||||
|
||||
@ -224,7 +247,7 @@ Dropbox settings are managed via the web UI on `/backup`.
|
||||
- Book deletion flow: `unlink` file → `prune_empty_dirs(parent)` → `DELETE FROM library` (cascade removes child rows).
|
||||
- Empty dir pruning: `prune_empty_dirs(start)` walks up from `start` to `LIBRARY_ROOT`, removing each dir if empty; stops at first non-empty dir.
|
||||
- Cover strategy:
|
||||
- EPUB: extracted from ZIP + cached in `library_cover_cache`
|
||||
- EPUB: `GET /library/cover/{filename}` checks `library_cover_cache` first; on miss, extracts from ZIP and warms the cache. Cover upload (`POST /library/cover/{filename}`) replaces the image inside the EPUB ZIP (OPF located via `META-INF/container.xml`, old cover found in manifest and removed) and updates the cache so subsequent requests return the new cover immediately.
|
||||
- PDF: first page rendered as thumbnail, cached
|
||||
- CBR/CBZ: first page extracted, cached
|
||||
- Rating storage:
|
||||
|
||||
@ -3,6 +3,64 @@
|
||||
This file tracks changes on the `develop` line.
|
||||
`changelog.md` can later be used for release summaries.
|
||||
|
||||
## 2026-03-25 (20)
|
||||
- Added Rated section to library sidebar: shows all non-archived books with `rating > 0`, sorted by rating descending then title alphabetically; badge displays total count; navigable via `#rated` URL hash
|
||||
|
||||
## 2026-03-25 (19)
|
||||
- Fixed series index 0 not displaying in series slot view, grid cards, list volume column, and book detail:
|
||||
- Series slot labels now show `#0` for all slots when the series has at least one positively-indexed sibling (consistent label height across all cards)
|
||||
- Grid card and list volume column use the same cross-book heuristic (`indexedSeriesSet`) to show `[0]` where appropriate
|
||||
- Book detail page queries whether any sibling in the same series has `series_index > 0` (`series_is_indexed`) and shows `[0]` accordingly
|
||||
|
||||
## 2026-03-25 (18)
|
||||
- Added autocomplete for Author, Publisher, and Series in the book edit panel: typing shows a filtered dropdown of existing values from the library; selecting a value fills the field (same dropdown styling as genres/tags)
|
||||
- Status field now defaults to "Complete" when opening the edit panel for a book that has no status set yet
|
||||
|
||||
## 2026-03-25 (17)
|
||||
- Fixed CBR reader showing only first page: `cbr_page_count` was missing from the `cbr` import in `reader.py`; `/api/cbr/info/` was returning an error, causing `page_count` to fall back to 1 and the Next button to remain disabled
|
||||
|
||||
## 2026-03-25 (16)
|
||||
- Fixed CBR/CBZ reader stuck on loading: added `/api/cbr/info/{filename}` endpoint returning `{page_count}`, and added a CBR/CBZ branch in `reader.html` that mirrors the PDF paged reader (page images served via `/library/cbr/{filename}/{page}`)
|
||||
|
||||
## 2026-03-25 (15)
|
||||
- Added series volume 0 and letter suffix support (e.g. "21a", "21b"):
|
||||
- New `series_suffix VARCHAR(10)` column added to `library` table via `migrate_series_suffix`
|
||||
- `series_index` lower bound changed from 1 to 0 throughout (index 0 = special/prequel edition)
|
||||
- Volume field in the book editor changed from `type="number"` to `type="text"` — accepts "0", "1", "21a", etc.
|
||||
- Server parses the combined volume string into `series_index` (INTEGER) + `series_suffix` (VARCHAR) via `parse_volume_str`
|
||||
- File naming includes suffix: `021a - Title.epub`
|
||||
- `novela:series_suffix` meta tag written to/read from EPUB OPF for persistence across rescans
|
||||
- Series detail view: books sorted by `(series_index, series_suffix)`; slot labels show "21a"; index 0 shown as slot when any sibling has index > 0
|
||||
- Grid cards, list view Volume column, author/publisher sort all include suffix
|
||||
|
||||
## 2026-03-25 (14)
|
||||
- Added multi-select and bulk delete to `All books` List view:
|
||||
- Checkbox column added to the list table (List mode only; Grid mode unchanged)
|
||||
- Select all / Clear all / Clear selection controls in the controls bar
|
||||
- `Shift+click` range selection on checkboxes
|
||||
- `Delete selected` button (red, disabled when nothing is selected) triggers a confirmation dialog; confirmed deletions remove files from disk and database, then reload the library
|
||||
|
||||
## 2026-03-25 (13)
|
||||
- Fixed EPUB cover replacement reverting to original after upload:
|
||||
- `GET /library/cover/{filename}` for EPUBs now checks the DB cover cache first (populated on upload) before falling back to extracting from the EPUB file; the cache is also warmed on cache-miss so subsequent requests are fast.
|
||||
- `add_cover_to_epub` fully rewritten: locates the OPF via `META-INF/container.xml`, finds the existing cover image in the OPF manifest, removes it from the ZIP, and writes the new cover in the same directory — works for any EPUB directory structure (`OEBPS/`, `EPUB/`, etc.).
|
||||
|
||||
## 2026-03-25 (12)
|
||||
- Fixed chapter images not loading in EPUBs that use a non-standard root directory (e.g. `EPUB/` instead of `OEBPS/`): image paths are now passed as full ZIP paths instead of stripping the root segment, and the image endpoint no longer hardcodes an `OEBPS/` prefix. Case-insensitive fallback retained for EPUBs with mismatched image folder casing.
|
||||
|
||||
## 2026-03-25 (11)
|
||||
- Fixed reader progress bar jumping to 100% immediately when navigating to the second chapter in short EPUBs (2 chapters): changed progress formula denominator from `total - 1` to `total` so 100% is only reached after fully scrolling through the last chapter. Same fix applied to PDF page progress.
|
||||
|
||||
## 2026-03-25 (10)
|
||||
- Added bookmark feature:
|
||||
- `bookmarks` table in DB (`migrate_create_bookmarks`): `filename`, `chapter_index`, `scroll_frac`, `chapter_title`, `note`, `created_at`
|
||||
- New API endpoints: `GET/POST /library/bookmarks/{filename}`, `PATCH/DELETE /library/bookmarks/{id}`, `GET /api/bookmarks`
|
||||
- Bookmark button in reader header (orange, bookmark icon): opens modal with optional note field
|
||||
- Bookmark modal closes on Escape, backdrop click, or Cancel
|
||||
- Reader supports `?bm_ch=N&bm_scroll=F` URL params to jump directly to bookmarked position (overrides saved progress)
|
||||
- Library sidebar: Bookmarks section in Library nav with live count badge
|
||||
- Library `#bookmarks` view: card list showing cover, book title, author, chapter, note, date, Go-to and Delete buttons
|
||||
|
||||
## 2026-03-22
|
||||
- Added blueprint/technical documentation structure in `docs/`.
|
||||
- Completed router split and bootstrap structure (`main.py`, routers, migrations, DB pool).
|
||||
@ -46,6 +104,33 @@ This file tracks changes on the `develop` line.
|
||||
- Updated Docker image with `postgresql-client` for `pg_dump`.
|
||||
- Multiple test builds pushed to `gitea.oskamp.info/ivooskamp/novela:dev`.
|
||||
|
||||
## 2026-03-25 (9)
|
||||
- Fixed backup progress not visible immediately after clicking Run Live Backup: sidebar `loadBackupProgress()` is now called directly from the backup page after a successful start
|
||||
- Fixed backup status timestamp showing wrong relative time (e.g. "1h ago" for a recent backup): server UTC timestamps without timezone suffix are now correctly interpreted as UTC in the browser
|
||||
- Fixed backup status bar using browser-default purple link color: now uses `var(--accent)` orange consistent with the rest of the UI
|
||||
- Added password manager ignore attributes to App Key and App Secret fields: `data-1p-ignore` (1Password), `data-lpignore="true"` (LastPass), `data-form-type="other"` (1Password v8)
|
||||
|
||||
## 2026-03-25 (8)
|
||||
- Added Dropbox OAuth2 refresh token support (no redirect URI needed):
|
||||
- New endpoints: `POST /api/backup/oauth/prepare` (generates auth URL) and `POST /api/backup/oauth/exchange` (exchanges code for refresh token)
|
||||
- `_dbx()` now prefers refresh token mode (app key + secret + refresh token) with automatic renewal; falls back to legacy access token for backwards compatibility
|
||||
- App key and secret stored encrypted in `credentials` table (`dropbox_app_key`, `dropbox_app_secret`)
|
||||
- `DELETE /api/backup/credentials` now also cleans up `dropbox_app_key` and `dropbox_app_secret`
|
||||
- Backup page updated with two-step OAuth flow UI (app key/secret → auth URL → paste code)
|
||||
- Settings status shows `• refresh token` or `• legacy token` indicator
|
||||
|
||||
## 2026-03-25 (7)
|
||||
- Added backup progress tracking: `GET /api/backup/progress` returns `{running, done, total, phase}`; sidebar shows live file count (e.g. `312 / 652 · uploading`) polling every 3 s while running
|
||||
|
||||
## 2026-03-25 (6)
|
||||
- Fixed `GET /api/backup/health` missing `schedule_enabled` and `schedule_interval_hours` in response (values were read but not returned; Health card showed "disabled" even when schedule was on)
|
||||
- Added backup status indicator in sidebar above Rescan library button: coloured dot + status text (OK/running/failed/never), links to `/backup`, auto-refreshes every 8 s while running, shows time-ago for last successful backup
|
||||
|
||||
## 2026-03-25 (5)
|
||||
- One-time path migration: all 571 existing library files moved to correct format-prefixed structure (`epub/`, `pdf/`, `comics/`) and all DB references updated (`migrate_paths.py --execute`)
|
||||
- Empty old publisher root directories pruned after migration
|
||||
- Recovery script (`recover_decock049.py`) written; confirmed file was added after last Dropbox backup so not recoverable — to be re-imported manually
|
||||
|
||||
## 2026-03-25 (4)
|
||||
- Added {publisher} directory to PDF and CBR/CBZ paths: `pdf/{publisher}/{author}/{title}.pdf`, `comics/{publisher}/{author}/{title}.cbr/cbz` — consistent with EPUB structure
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user