Implement library/home/stats/backup updates and refresh docs
This commit is contained in:
parent
ced5b25dbe
commit
58268a4906
@ -6,6 +6,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from db import close_pool, init_pool
|
||||
from migrations import run_migrations
|
||||
from routers.backup import start_backup_scheduler, stop_backup_scheduler
|
||||
from routers import (
|
||||
backup_router,
|
||||
editor_router,
|
||||
@ -20,9 +21,11 @@ from routers import (
|
||||
async def lifespan(app: FastAPI):
|
||||
init_pool()
|
||||
run_migrations()
|
||||
await start_backup_scheduler()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_backup_scheduler()
|
||||
close_pool()
|
||||
|
||||
|
||||
|
||||
@ -123,12 +123,14 @@ def migrate_create_credentials() -> None:
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
site VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
_exec("ALTER TABLE credentials ALTER COLUMN username TYPE TEXT")
|
||||
_exec("ALTER TABLE credentials ALTER COLUMN password TYPE TEXT")
|
||||
|
||||
|
||||
def migrate_create_break_patterns() -> None:
|
||||
@ -191,6 +193,40 @@ def migrate_create_backup_log() -> None:
|
||||
)
|
||||
|
||||
|
||||
def migrate_create_perf_indexes() -> None:
|
||||
# Match library list sorting and common filters.
|
||||
_exec(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_library_sort_coalesce
|
||||
ON library (
|
||||
(COALESCE(publisher, '')),
|
||||
(COALESCE(author, '')),
|
||||
(COALESCE(series, '')),
|
||||
series_index,
|
||||
(COALESCE(title, ''))
|
||||
)
|
||||
"""
|
||||
)
|
||||
_exec("CREATE INDEX IF NOT EXISTS idx_library_needs_review ON library (needs_review)")
|
||||
_exec("CREATE INDEX IF NOT EXISTS idx_library_archived ON library (archived)")
|
||||
|
||||
# Speeds grouped reads + recent-read lookups.
|
||||
_exec(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_reading_sessions_filename_readat
|
||||
ON reading_sessions (filename, read_at DESC)
|
||||
"""
|
||||
)
|
||||
|
||||
# Helps ORDER BY filename, tag fetch for tag-map construction.
|
||||
_exec(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_book_tags_filename_tag
|
||||
ON book_tags (filename, tag)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
migrate_create_library()
|
||||
migrate_create_book_tags()
|
||||
@ -200,4 +236,5 @@ def run_migrations() -> None:
|
||||
migrate_create_credentials()
|
||||
migrate_create_break_patterns()
|
||||
migrate_create_backup_log()
|
||||
migrate_create_perf_indexes()
|
||||
migrate_seed_break_patterns()
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@ -22,14 +24,21 @@ LIBRARY_DIR = Path(os.environ.get("LIBRARY_DIR", "library"))
|
||||
CONFIG_DIR = Path(os.environ.get("CONFIG_DIR", "config"))
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
MANIFEST_PATH = CONFIG_DIR / "backup_manifest.json"
|
||||
DROPBOX_ROOT = (os.environ.get("DROPBOX_BACKUP_ROOT", "/novela") or "/novela").rstrip("/")
|
||||
DEFAULT_DROPBOX_ROOT = "/novela"
|
||||
DEFAULT_RETENTION_COUNT = 14
|
||||
DEFAULT_SCHEDULE_ENABLED = False
|
||||
DEFAULT_SCHEDULE_INTERVAL_HOURS = 24
|
||||
|
||||
|
||||
BACKUP_TASKS: dict[int, asyncio.Task] = {}
|
||||
SCHEDULER_TASK: asyncio.Task | None = None
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _load_manifest() -> dict[str, dict[str, float | int]]:
|
||||
def _load_manifest() -> dict[str, dict[str, float | int | str]]:
|
||||
if not MANIFEST_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
@ -41,22 +50,25 @@ def _load_manifest() -> dict[str, dict[str, float | int]]:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_manifest(manifest: dict[str, dict[str, float | int]]) -> None:
|
||||
def _save_manifest(manifest: dict[str, dict[str, float | int | str]]) -> None:
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
|
||||
def _load_dropbox_token() -> str:
|
||||
def _dropbox_credential_details() -> dict:
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT username, password FROM credentials WHERE site = 'dropbox' LIMIT 1")
|
||||
cur.execute(
|
||||
"SELECT username, password, updated_at FROM credentials WHERE site = 'dropbox' LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return ""
|
||||
return {"configured": False, "token": "", "updated_at": None}
|
||||
|
||||
username_raw, password_raw = row
|
||||
username_raw, password_raw, updated_at = row
|
||||
username = decrypt_value(username_raw)
|
||||
password = decrypt_value(password_raw)
|
||||
token = (password or username or "").strip()
|
||||
|
||||
if not is_encrypted_value(username_raw) or not is_encrypted_value(password_raw):
|
||||
cur.execute(
|
||||
@ -64,11 +76,252 @@ def _load_dropbox_token() -> str:
|
||||
UPDATE credentials
|
||||
SET username = %s, password = %s, updated_at = NOW()
|
||||
WHERE site = 'dropbox'
|
||||
RETURNING updated_at
|
||||
""",
|
||||
(encrypt_value(username), encrypt_value(password)),
|
||||
)
|
||||
upd = cur.fetchone()
|
||||
if upd:
|
||||
updated_at = upd[0]
|
||||
|
||||
return (password or username or "").strip()
|
||||
return {
|
||||
"configured": bool(token),
|
||||
"token": token,
|
||||
"updated_at": updated_at.isoformat() if updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _load_dropbox_token() -> str:
|
||||
return _dropbox_credential_details().get("token", "")
|
||||
|
||||
|
||||
def _normalize_dropbox_root(value: str | None) -> str:
|
||||
root = (value or "").strip() or DEFAULT_DROPBOX_ROOT
|
||||
if not root.startswith("/"):
|
||||
root = "/" + root
|
||||
root = "/" + "/".join(part for part in root.split("/") if part)
|
||||
return root or DEFAULT_DROPBOX_ROOT
|
||||
|
||||
|
||||
def _dropbox_root_details() -> dict:
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT username, password, updated_at FROM credentials WHERE site = 'dropbox_backup_root' LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
env_val = os.environ.get("DROPBOX_BACKUP_ROOT", DEFAULT_DROPBOX_ROOT)
|
||||
return {
|
||||
"root": _normalize_dropbox_root(env_val),
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
username_raw, password_raw, updated_at = row
|
||||
username = decrypt_value(username_raw)
|
||||
password = decrypt_value(password_raw)
|
||||
root = _normalize_dropbox_root(password or username or DEFAULT_DROPBOX_ROOT)
|
||||
|
||||
if not is_encrypted_value(username_raw) or not is_encrypted_value(password_raw):
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE credentials
|
||||
SET username = %s, password = %s, updated_at = NOW()
|
||||
WHERE site = 'dropbox_backup_root'
|
||||
RETURNING updated_at
|
||||
""",
|
||||
(encrypt_value(""), encrypt_value(root)),
|
||||
)
|
||||
upd = cur.fetchone()
|
||||
if upd:
|
||||
updated_at = upd[0]
|
||||
|
||||
return {
|
||||
"root": root,
|
||||
"updated_at": updated_at.isoformat() if updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _load_dropbox_root() -> str:
|
||||
return _dropbox_root_details().get("root", DEFAULT_DROPBOX_ROOT)
|
||||
|
||||
|
||||
def _dropbox_retention_details() -> dict:
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT username, password, updated_at FROM credentials WHERE site = 'dropbox_backup_retention' LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return {"retention_count": DEFAULT_RETENTION_COUNT, "updated_at": None}
|
||||
|
||||
username_raw, password_raw, updated_at = row
|
||||
username = decrypt_value(username_raw)
|
||||
password = decrypt_value(password_raw)
|
||||
raw = (password or username or "").strip()
|
||||
try:
|
||||
retention_count = max(1, int(raw))
|
||||
except Exception:
|
||||
retention_count = DEFAULT_RETENTION_COUNT
|
||||
|
||||
if not is_encrypted_value(username_raw) or not is_encrypted_value(password_raw):
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE credentials
|
||||
SET username = %s, password = %s, updated_at = NOW()
|
||||
WHERE site = 'dropbox_backup_retention'
|
||||
RETURNING updated_at
|
||||
""",
|
||||
(encrypt_value(""), encrypt_value(str(retention_count))),
|
||||
)
|
||||
upd = cur.fetchone()
|
||||
if upd:
|
||||
updated_at = upd[0]
|
||||
|
||||
return {
|
||||
"retention_count": retention_count,
|
||||
"updated_at": updated_at.isoformat() if updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _load_dropbox_retention_count() -> int:
|
||||
return int(_dropbox_retention_details().get("retention_count", DEFAULT_RETENTION_COUNT))
|
||||
|
||||
|
||||
def _dropbox_schedule_details() -> dict:
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT username, password, updated_at FROM credentials WHERE site = 'dropbox_backup_schedule' LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return {
|
||||
"enabled": DEFAULT_SCHEDULE_ENABLED,
|
||||
"interval_hours": DEFAULT_SCHEDULE_INTERVAL_HOURS,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
username_raw, password_raw, updated_at = row
|
||||
username = decrypt_value(username_raw)
|
||||
password = decrypt_value(password_raw)
|
||||
raw = (password or username or "").strip().lower()
|
||||
|
||||
enabled = False
|
||||
interval_hours = DEFAULT_SCHEDULE_INTERVAL_HOURS
|
||||
try:
|
||||
obj = json.loads(raw) if raw.startswith("{") else None
|
||||
except Exception:
|
||||
obj = None
|
||||
|
||||
if isinstance(obj, dict):
|
||||
enabled = bool(obj.get("enabled", DEFAULT_SCHEDULE_ENABLED))
|
||||
try:
|
||||
interval_hours = max(1, int(obj.get("interval_hours", DEFAULT_SCHEDULE_INTERVAL_HOURS)))
|
||||
except Exception:
|
||||
interval_hours = DEFAULT_SCHEDULE_INTERVAL_HOURS
|
||||
else:
|
||||
parts = raw.split(":")
|
||||
if len(parts) == 2:
|
||||
enabled = parts[0] in {"1", "true", "yes", "on"}
|
||||
try:
|
||||
interval_hours = max(1, int(parts[1]))
|
||||
except Exception:
|
||||
interval_hours = DEFAULT_SCHEDULE_INTERVAL_HOURS
|
||||
|
||||
norm = json.dumps({"enabled": enabled, "interval_hours": interval_hours}, separators=(",", ":"))
|
||||
if not is_encrypted_value(username_raw) or not is_encrypted_value(password_raw):
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE credentials
|
||||
SET username = %s, password = %s, updated_at = NOW()
|
||||
WHERE site = 'dropbox_backup_schedule'
|
||||
RETURNING updated_at
|
||||
""",
|
||||
(encrypt_value(""), encrypt_value(norm)),
|
||||
)
|
||||
upd = cur.fetchone()
|
||||
if upd:
|
||||
updated_at = upd[0]
|
||||
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"interval_hours": interval_hours,
|
||||
"updated_at": updated_at.isoformat() if updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _load_backup_schedule() -> tuple[bool, int]:
|
||||
d = _dropbox_schedule_details()
|
||||
return bool(d.get("enabled", DEFAULT_SCHEDULE_ENABLED)), int(d.get("interval_hours", DEFAULT_SCHEDULE_INTERVAL_HOURS))
|
||||
|
||||
|
||||
def _save_backup_schedule(enabled: bool, interval_hours: int) -> None:
|
||||
interval = max(1, int(interval_hours))
|
||||
payload = json.dumps({"enabled": bool(enabled), "interval_hours": interval}, separators=(",", ":"))
|
||||
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_backup_schedule', %s, %s, NOW())
|
||||
ON CONFLICT (site) DO UPDATE
|
||||
SET username = EXCLUDED.username,
|
||||
password = EXCLUDED.password,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(encrypt_value(""), encrypt_value(payload)),
|
||||
)
|
||||
|
||||
|
||||
def _dropbox_join(root: str, *parts: str) -> str:
|
||||
clean_root = _normalize_dropbox_root(root)
|
||||
segs = [p.strip("/") for p in parts if p and p.strip("/")]
|
||||
if clean_root == "/":
|
||||
return "/" + "/".join(segs) if segs else "/"
|
||||
if not segs:
|
||||
return clean_root
|
||||
return clean_root + "/" + "/".join(segs)
|
||||
|
||||
|
||||
def _save_dropbox_root(root: 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_backup_root', %s, %s, NOW())
|
||||
ON CONFLICT (site) DO UPDATE
|
||||
SET username = EXCLUDED.username,
|
||||
password = EXCLUDED.password,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(encrypt_value(""), encrypt_value(_normalize_dropbox_root(root))),
|
||||
)
|
||||
|
||||
|
||||
def _save_dropbox_retention_count(retention_count: int) -> None:
|
||||
val = max(1, int(retention_count))
|
||||
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_backup_retention', %s, %s, NOW())
|
||||
ON CONFLICT (site) DO UPDATE
|
||||
SET username = EXCLUDED.username,
|
||||
password = EXCLUDED.password,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(encrypt_value(""), encrypt_value(str(val))),
|
||||
)
|
||||
|
||||
|
||||
def _dbx() -> dropbox.Dropbox:
|
||||
@ -105,6 +358,48 @@ def _dropbox_upload_bytes(client: dropbox.Dropbox, target_path: str, data: bytes
|
||||
return len(data)
|
||||
|
||||
|
||||
def _dropbox_exists(client: dropbox.Dropbox, path: str) -> bool:
|
||||
try:
|
||||
client.files_get_metadata(path)
|
||||
return True
|
||||
except ApiError as e:
|
||||
text = str(e).lower()
|
||||
if "not_found" in text or "path/not_found" in text:
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def _dropbox_list_files_recursive(client: dropbox.Dropbox, root: str) -> list[str]:
|
||||
paths: list[str] = []
|
||||
try:
|
||||
res = client.files_list_folder(root, recursive=True)
|
||||
except ApiError as e:
|
||||
text = str(e).lower()
|
||||
if "not_found" in text or "path/not_found" in text:
|
||||
return []
|
||||
raise
|
||||
|
||||
while True:
|
||||
for entry in res.entries:
|
||||
if isinstance(entry, dropbox.files.FileMetadata):
|
||||
paths.append(entry.path_lower or entry.path_display or "")
|
||||
if not res.has_more:
|
||||
break
|
||||
res = client.files_list_folder_continue(res.cursor)
|
||||
return [p for p in paths if p]
|
||||
|
||||
|
||||
def _dropbox_delete_paths(client: dropbox.Dropbox, paths: list[str]) -> int:
|
||||
deleted = 0
|
||||
for p in paths:
|
||||
try:
|
||||
client.files_delete_v2(p)
|
||||
deleted += 1
|
||||
except ApiError:
|
||||
pass
|
||||
return deleted
|
||||
|
||||
|
||||
def _iter_library_files() -> list[Path]:
|
||||
if not LIBRARY_DIR.exists():
|
||||
return []
|
||||
@ -116,6 +411,23 @@ def _current_file_state(path: Path) -> dict[str, float | int]:
|
||||
return {"mtime": st.st_mtime, "size": st.st_size}
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _snapshot_name() -> str:
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
return f"snapshot-{stamp}.json"
|
||||
|
||||
|
||||
def _object_path(objects_root: str, sha256: str) -> str:
|
||||
return _dropbox_join(objects_root, sha256[:2], sha256)
|
||||
|
||||
|
||||
def _pg_dump_cmd(tmp_path: Path) -> list[str]:
|
||||
return [
|
||||
"pg_dump",
|
||||
@ -153,6 +465,39 @@ def _run_pg_dump() -> tuple[bytes, str]:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _has_running_backup() -> bool:
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM backup_log
|
||||
WHERE status = 'running' AND finished_at IS NULL
|
||||
ORDER BY started_at DESC
|
||||
"""
|
||||
)
|
||||
rows = [int(r[0]) for r in cur.fetchall()]
|
||||
if not rows:
|
||||
return False
|
||||
|
||||
active_ids = set(BACKUP_TASKS.keys())
|
||||
stale_ids = [rid for rid in rows if rid not in active_ids]
|
||||
if stale_ids:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE backup_log
|
||||
SET status = 'error',
|
||||
error_msg = COALESCE(error_msg, 'Interrupted: service restart or crash'),
|
||||
finished_at = NOW()
|
||||
WHERE id = ANY(%s)
|
||||
""",
|
||||
(stale_ids,),
|
||||
)
|
||||
|
||||
return any(rid in active_ids for rid in rows)
|
||||
|
||||
|
||||
def _insert_backup_log_running() -> int:
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
@ -185,6 +530,62 @@ def _finish_backup_log(log_id: int, *, status: str, files_count: int | None, siz
|
||||
)
|
||||
|
||||
|
||||
def _list_snapshot_paths(client: dropbox.Dropbox, snapshots_root: str) -> list[str]:
|
||||
files = _dropbox_list_files_recursive(client, snapshots_root)
|
||||
return sorted([p for p in files if p.endswith(".json")], reverse=True)
|
||||
|
||||
|
||||
def _load_snapshot_data(client: dropbox.Dropbox, snapshot_path: str) -> dict:
|
||||
_meta, res = client.files_download(snapshot_path)
|
||||
raw = res.content
|
||||
parsed = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _enforce_snapshot_retention(
|
||||
client: dropbox.Dropbox,
|
||||
snapshots_root: str,
|
||||
keep_count: int,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
all_snapshots = _list_snapshot_paths(client, snapshots_root)
|
||||
keep = max(1, int(keep_count))
|
||||
kept = all_snapshots[:keep]
|
||||
to_delete = all_snapshots[keep:]
|
||||
if to_delete:
|
||||
_dropbox_delete_paths(client, to_delete)
|
||||
return kept, to_delete
|
||||
|
||||
|
||||
def _collect_hashes_from_snapshots(client: dropbox.Dropbox, snapshot_paths: list[str]) -> set[str]:
|
||||
used: set[str] = set()
|
||||
for path in snapshot_paths:
|
||||
try:
|
||||
snap = _load_snapshot_data(client, path)
|
||||
except Exception:
|
||||
continue
|
||||
files = snap.get("files", {}) if isinstance(snap, dict) else {}
|
||||
if not isinstance(files, dict):
|
||||
continue
|
||||
for item in files.values():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
sha = str(item.get("sha256") or "").lower()
|
||||
if len(sha) == 64 and all(c in "0123456789abcdef" for c in sha):
|
||||
used.add(sha)
|
||||
return used
|
||||
|
||||
|
||||
def _prune_orphan_objects(client: dropbox.Dropbox, objects_root: str, referenced_hashes: set[str]) -> int:
|
||||
object_files = _dropbox_list_files_recursive(client, objects_root)
|
||||
to_delete: list[str] = []
|
||||
for p in object_files:
|
||||
name = Path(p).name.lower()
|
||||
if len(name) == 64 and all(c in "0123456789abcdef" for c in name):
|
||||
if name not in referenced_hashes:
|
||||
to_delete.append(p)
|
||||
return _dropbox_delete_paths(client, to_delete)
|
||||
|
||||
|
||||
def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]:
|
||||
client = None if dry_run else _dbx()
|
||||
manifest = _load_manifest()
|
||||
@ -192,30 +593,76 @@ def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]:
|
||||
|
||||
uploaded_count = 0
|
||||
uploaded_size = 0
|
||||
new_manifest: dict[str, dict[str, float | int]] = {}
|
||||
new_manifest: dict[str, dict[str, float | int | str]] = {}
|
||||
|
||||
dropbox_root = _load_dropbox_root()
|
||||
retention_count = _load_dropbox_retention_count()
|
||||
|
||||
objects_root = _dropbox_join(dropbox_root, "library_objects")
|
||||
snapshots_root = _dropbox_join(dropbox_root, "library_snapshots")
|
||||
|
||||
library_root = f"{DROPBOX_ROOT}/library"
|
||||
if client is not None:
|
||||
_ensure_dropbox_dir(client, library_root)
|
||||
_ensure_dropbox_dir(client, objects_root)
|
||||
_ensure_dropbox_dir(client, snapshots_root)
|
||||
|
||||
snapshot_files: dict[str, dict[str, float | int | str]] = {}
|
||||
|
||||
for path in files:
|
||||
rel = path.relative_to(LIBRARY_DIR).as_posix()
|
||||
state = _current_file_state(path)
|
||||
new_manifest[rel] = state
|
||||
prev = manifest.get(rel, {}) if isinstance(manifest.get(rel), dict) else {}
|
||||
|
||||
if manifest.get(rel) == state:
|
||||
continue
|
||||
|
||||
data = path.read_bytes()
|
||||
target = f"{library_root}/{rel}"
|
||||
if client is not None:
|
||||
uploaded_size += _dropbox_upload_bytes(client, target, data)
|
||||
sha256 = ""
|
||||
if (
|
||||
prev
|
||||
and prev.get("mtime") == state["mtime"]
|
||||
and prev.get("size") == state["size"]
|
||||
and isinstance(prev.get("sha256"), str)
|
||||
):
|
||||
sha256 = str(prev.get("sha256"))
|
||||
else:
|
||||
uploaded_size += len(data)
|
||||
sha256 = _sha256_file(path)
|
||||
|
||||
entry = {"mtime": state["mtime"], "size": state["size"], "sha256": sha256}
|
||||
new_manifest[rel] = entry
|
||||
snapshot_files[rel] = entry
|
||||
|
||||
object_target = _object_path(objects_root, sha256)
|
||||
if client is not None:
|
||||
if not _dropbox_exists(client, object_target):
|
||||
data = path.read_bytes()
|
||||
uploaded_size += _dropbox_upload_bytes(client, object_target, data)
|
||||
uploaded_count += 1
|
||||
else:
|
||||
# Dry run reports potential upload work for changed objects.
|
||||
if not prev or prev.get("sha256") != sha256:
|
||||
uploaded_size += int(state["size"])
|
||||
uploaded_count += 1
|
||||
|
||||
snapshot = {
|
||||
"created_at": _now_iso(),
|
||||
"retention_count": retention_count,
|
||||
"files": snapshot_files,
|
||||
}
|
||||
snapshot_data = json.dumps(snapshot, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
snapshot_name = _snapshot_name()
|
||||
snapshot_target = _dropbox_join(snapshots_root, snapshot_name)
|
||||
|
||||
if client is not None:
|
||||
uploaded_size += _dropbox_upload_bytes(client, snapshot_target, snapshot_data)
|
||||
uploaded_count += 1
|
||||
|
||||
kept_snapshots, _deleted_snapshots = _enforce_snapshot_retention(
|
||||
client, snapshots_root, retention_count
|
||||
)
|
||||
referenced_hashes = _collect_hashes_from_snapshots(client, kept_snapshots)
|
||||
_prune_orphan_objects(client, objects_root, referenced_hashes)
|
||||
else:
|
||||
uploaded_size += len(snapshot_data)
|
||||
uploaded_count += 1
|
||||
|
||||
dump_data, dump_name = _run_pg_dump()
|
||||
dump_target = f"{DROPBOX_ROOT}/postgres/{dump_name}"
|
||||
dump_target = _dropbox_join(dropbox_root, "postgres", dump_name)
|
||||
if client is not None:
|
||||
uploaded_size += _dropbox_upload_bytes(client, dump_target, dump_data)
|
||||
else:
|
||||
@ -235,6 +682,97 @@ async def backup_page(request: Request):
|
||||
return templates.TemplateResponse(request, template, {"active": "backup"})
|
||||
|
||||
|
||||
@router.get("/api/backup/credentials")
|
||||
async def backup_dropbox_credentials():
|
||||
details = _dropbox_credential_details()
|
||||
root_details = _dropbox_root_details()
|
||||
retention_details = _dropbox_retention_details()
|
||||
token = details.get("token", "")
|
||||
preview = ""
|
||||
if token:
|
||||
preview = f"{token[:4]}...{token[-4:]}" if len(token) >= 10 else "(configured)"
|
||||
return {
|
||||
"configured": bool(token),
|
||||
"token_preview": preview,
|
||||
"updated_at": details.get("updated_at"),
|
||||
"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)),
|
||||
"retention_updated_at": retention_details.get("updated_at"),
|
||||
"schedule_enabled": _dropbox_schedule_details().get("enabled", DEFAULT_SCHEDULE_ENABLED),
|
||||
"schedule_interval_hours": _dropbox_schedule_details().get("interval_hours", DEFAULT_SCHEDULE_INTERVAL_HOURS),
|
||||
"schedule_updated_at": _dropbox_schedule_details().get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/backup/credentials")
|
||||
async def backup_dropbox_credentials_save(request: Request):
|
||||
body = {}
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
existing_token = _load_dropbox_token()
|
||||
token = (body.get("token") or "").strip() or existing_token
|
||||
if not token:
|
||||
return {"ok": False, "error": "Dropbox token is required."}
|
||||
|
||||
dropbox_root = _normalize_dropbox_root(body.get("dropbox_root") or _load_dropbox_root())
|
||||
raw_retention = body.get("retention_count", _load_dropbox_retention_count())
|
||||
try:
|
||||
retention_count = max(1, int(raw_retention))
|
||||
except Exception:
|
||||
retention_count = DEFAULT_RETENTION_COUNT
|
||||
|
||||
schedule_enabled = bool(body.get("schedule_enabled", _load_backup_schedule()[0]))
|
||||
raw_interval = body.get("schedule_interval_hours", _load_backup_schedule()[1])
|
||||
try:
|
||||
schedule_interval_hours = max(1, int(raw_interval))
|
||||
except Exception:
|
||||
schedule_interval_hours = DEFAULT_SCHEDULE_INTERVAL_HOURS
|
||||
|
||||
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(token)),
|
||||
)
|
||||
|
||||
_save_dropbox_root(dropbox_root)
|
||||
_save_dropbox_retention_count(retention_count)
|
||||
_save_backup_schedule(schedule_enabled, schedule_interval_hours)
|
||||
return {
|
||||
"ok": True,
|
||||
"dropbox_root": dropbox_root,
|
||||
"retention_count": retention_count,
|
||||
"schedule_enabled": schedule_enabled,
|
||||
"schedule_interval_hours": schedule_interval_hours,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.delete("/api/backup/credentials")
|
||||
async def backup_dropbox_credentials_delete():
|
||||
with get_db_conn() as conn:
|
||||
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')"
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/api/backup/health")
|
||||
async def backup_health():
|
||||
token_present = bool(_load_dropbox_token())
|
||||
@ -249,10 +787,16 @@ async def backup_health():
|
||||
except Exception as e:
|
||||
dropbox_error = str(e)
|
||||
|
||||
dropbox_root = _load_dropbox_root()
|
||||
retention_count = _load_dropbox_retention_count()
|
||||
schedule_enabled, schedule_interval_hours = _load_backup_schedule()
|
||||
|
||||
return {
|
||||
"token_present": token_present,
|
||||
"dropbox_ok": dropbox_ok,
|
||||
"dropbox_error": dropbox_error,
|
||||
"dropbox_root": dropbox_root,
|
||||
"retention_count": retention_count,
|
||||
"pg_dump_available": bool(pg_dump_path),
|
||||
"pg_dump_path": pg_dump_path,
|
||||
"library_exists": LIBRARY_DIR.exists(),
|
||||
@ -313,6 +857,87 @@ async def backup_history():
|
||||
]
|
||||
|
||||
|
||||
def _start_backup_task(*, dry_run: bool) -> int:
|
||||
log_id = _insert_backup_log_running()
|
||||
task = asyncio.create_task(_run_backup_job(log_id, dry_run))
|
||||
BACKUP_TASKS[log_id] = task
|
||||
return log_id
|
||||
|
||||
|
||||
def _is_scheduled_backup_due(interval_hours: int) -> bool:
|
||||
with get_db_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT finished_at
|
||||
FROM backup_log
|
||||
WHERE status = 'success' AND finished_at IS NOT NULL
|
||||
ORDER BY finished_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row or not row[0]:
|
||||
return True
|
||||
|
||||
last = row[0]
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
return (now - last).total_seconds() >= max(1, int(interval_hours)) * 3600
|
||||
|
||||
|
||||
async def _scheduler_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
enabled, interval_hours = _load_backup_schedule()
|
||||
if enabled and not _has_running_backup() and _is_scheduled_backup_due(interval_hours):
|
||||
_start_backup_task(dry_run=False)
|
||||
except Exception:
|
||||
# Keep scheduler alive; errors are visible in backup history when runs fail.
|
||||
pass
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
async def start_backup_scheduler() -> None:
|
||||
global SCHEDULER_TASK
|
||||
if SCHEDULER_TASK is None or SCHEDULER_TASK.done():
|
||||
SCHEDULER_TASK = asyncio.create_task(_scheduler_loop())
|
||||
|
||||
|
||||
async def stop_backup_scheduler() -> None:
|
||||
global SCHEDULER_TASK
|
||||
if SCHEDULER_TASK is not None:
|
||||
SCHEDULER_TASK.cancel()
|
||||
try:
|
||||
await SCHEDULER_TASK
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
SCHEDULER_TASK = None
|
||||
|
||||
|
||||
async def _run_backup_job(log_id: int, dry_run: bool) -> None:
|
||||
try:
|
||||
files_count, size_bytes = await asyncio.to_thread(_run_backup_internal, dry_run=dry_run)
|
||||
_finish_backup_log(
|
||||
log_id,
|
||||
status="success",
|
||||
files_count=files_count,
|
||||
size_bytes=size_bytes,
|
||||
error_msg=None,
|
||||
)
|
||||
except Exception as e:
|
||||
_finish_backup_log(
|
||||
log_id,
|
||||
status="error",
|
||||
files_count=None,
|
||||
size_bytes=None,
|
||||
error_msg=str(e),
|
||||
)
|
||||
finally:
|
||||
BACKUP_TASKS.pop(log_id, None)
|
||||
|
||||
|
||||
@router.post("/api/backup/run")
|
||||
async def run_backup(request: Request):
|
||||
body = {}
|
||||
@ -322,38 +947,21 @@ async def run_backup(request: Request):
|
||||
pass
|
||||
dry_run = bool(body.get("dry_run", False))
|
||||
|
||||
log_id = _insert_backup_log_running()
|
||||
try:
|
||||
files_count, size_bytes = _run_backup_internal(dry_run=dry_run)
|
||||
_finish_backup_log(
|
||||
log_id,
|
||||
status="success",
|
||||
files_count=files_count,
|
||||
size_bytes=size_bytes,
|
||||
error_msg=None,
|
||||
)
|
||||
if _has_running_backup():
|
||||
return {
|
||||
"ok": False,
|
||||
"status": "running",
|
||||
"error": "A backup is already running.",
|
||||
"finished_at": _now_iso(),
|
||||
}
|
||||
|
||||
log_id = _start_backup_task(dry_run=dry_run)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"backup_id": log_id,
|
||||
"status": "success",
|
||||
"status": "running",
|
||||
"dry_run": dry_run,
|
||||
"files_count": files_count,
|
||||
"size_bytes": size_bytes,
|
||||
"finished_at": _now_iso(),
|
||||
}
|
||||
except Exception as e:
|
||||
_finish_backup_log(
|
||||
log_id,
|
||||
status="error",
|
||||
files_count=None,
|
||||
size_bytes=None,
|
||||
error_msg=str(e),
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"backup_id": log_id,
|
||||
"status": "error",
|
||||
"dry_run": dry_run,
|
||||
"error": str(e),
|
||||
"finished_at": _now_iso(),
|
||||
"message": "Backup started in background.",
|
||||
"started_at": _now_iso(),
|
||||
}
|
||||
|
||||
@ -346,22 +346,23 @@ def list_library_json() -> list[dict]:
|
||||
l.series, l.series_index, l.publication_status, l.want_to_read,
|
||||
l.archived, l.needs_review, l.updated_at,
|
||||
rp.progress, rp.cfi, rp.page,
|
||||
COUNT(rs.id)::int AS read_count,
|
||||
MAX(rs.read_at) AS last_read
|
||||
COALESCE(rs.read_count, 0)::int AS read_count,
|
||||
rs.last_read,
|
||||
(cc.filename IS NOT NULL) AS has_cached_cover
|
||||
FROM library l
|
||||
LEFT JOIN reading_progress rp ON rp.filename = l.filename
|
||||
LEFT JOIN reading_sessions rs ON rs.filename = l.filename
|
||||
GROUP BY l.filename, l.media_type, l.title, l.author, l.publisher, l.has_cover,
|
||||
l.series, l.series_index, l.publication_status, l.want_to_read,
|
||||
l.archived, l.needs_review, l.updated_at, rp.progress, rp.cfi, rp.page
|
||||
LEFT JOIN (
|
||||
SELECT filename, COUNT(*)::int AS read_count, MAX(read_at) AS last_read
|
||||
FROM reading_sessions
|
||||
GROUP BY filename
|
||||
) rs ON rs.filename = l.filename
|
||||
LEFT JOIN library_cover_cache cc ON cc.filename = l.filename
|
||||
ORDER BY COALESCE(l.publisher, ''), COALESCE(l.author, ''), COALESCE(l.series, ''), l.series_index, COALESCE(l.title, '')
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT filename, tag, tag_type FROM book_tags ORDER BY tag")
|
||||
cur.execute("SELECT filename, tag, tag_type FROM book_tags ORDER BY filename, tag")
|
||||
tags = cur.fetchall()
|
||||
cur.execute("SELECT filename FROM library_cover_cache")
|
||||
cached = {r[0] for r in cur.fetchall()}
|
||||
|
||||
tag_map: dict[str, list[dict]] = {}
|
||||
for filename, tag, tag_type in tags:
|
||||
@ -377,7 +378,7 @@ def list_library_json() -> list[dict]:
|
||||
"author": r[3] or "",
|
||||
"publisher": r[4] or "",
|
||||
"has_cover": bool(r[5]),
|
||||
"has_cached_cover": r[0] in cached,
|
||||
"has_cached_cover": bool(r[18]),
|
||||
"series": r[6] or "",
|
||||
"series_index": r[7] or 0,
|
||||
"publication_status": r[8] or "",
|
||||
|
||||
@ -71,9 +71,14 @@ async def library_page(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/library")
|
||||
async def api_library():
|
||||
async def api_library(rescan: bool = False, include_file_info: bool = False):
|
||||
# Fast path: avoid expensive full disk scan on every library page load.
|
||||
# Use /library/rescan (or ?rescan=true) when a full sync is needed.
|
||||
if rescan:
|
||||
_sync_disk_to_db()
|
||||
|
||||
books = list_library_json()
|
||||
if include_file_info:
|
||||
for b in books:
|
||||
p = resolve_library_path(b["filename"])
|
||||
if p and p.exists():
|
||||
@ -308,6 +313,46 @@ async def library_archive(filename: str):
|
||||
return {"ok": True, "archived": val}
|
||||
|
||||
|
||||
@router.post("/library/new/mark-reviewed")
|
||||
async def library_mark_new_reviewed(request: Request):
|
||||
body = await request.json()
|
||||
filenames = body.get("filenames", [])
|
||||
if not isinstance(filenames, list):
|
||||
return {"error": "filenames must be a list"}
|
||||
|
||||
cleaned: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in filenames:
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
name = raw.strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
full = resolve_library_path(name)
|
||||
if full is None:
|
||||
continue
|
||||
cleaned.append(name)
|
||||
seen.add(name)
|
||||
|
||||
if not cleaned:
|
||||
return {"ok": True, "updated": 0}
|
||||
|
||||
placeholders = ", ".join(["%s"] * len(cleaned))
|
||||
with get_db_conn() as conn:
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE library
|
||||
SET needs_review = FALSE, updated_at = NOW()
|
||||
WHERE filename IN ({placeholders})
|
||||
""",
|
||||
tuple(cleaned),
|
||||
)
|
||||
updated = cur.rowcount or 0
|
||||
return {"ok": True, "updated": updated}
|
||||
|
||||
|
||||
@router.get("/home", response_class=HTMLResponse)
|
||||
async def home_page(request: Request):
|
||||
return templates.TemplateResponse(request, "home.html", {"active": "home"})
|
||||
@ -319,30 +364,165 @@ async def api_home():
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT l.filename, l.title, l.author, l.media_type,
|
||||
SELECT l.filename, l.title, l.author, l.has_cover,
|
||||
l.series, l.series_index, l.publication_status,
|
||||
l.media_type,
|
||||
COALESCE(rp.progress, 0) AS progress,
|
||||
MAX(rs.read_at) AS last_read
|
||||
FROM library l
|
||||
LEFT JOIN reading_progress rp ON rp.filename = l.filename
|
||||
LEFT JOIN reading_sessions rs ON rs.filename = l.filename
|
||||
GROUP BY l.filename, l.title, l.author, l.media_type, rp.progress
|
||||
ORDER BY last_read DESC NULLS LAST, l.updated_at DESC
|
||||
LIMIT 30
|
||||
rp.cfi
|
||||
FROM reading_progress rp
|
||||
JOIN library l ON l.filename = rp.filename
|
||||
WHERE rp.progress > 0
|
||||
AND l.archived = FALSE
|
||||
ORDER BY rp.updated_at DESC
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cr_rows = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type
|
||||
FROM library l
|
||||
LEFT JOIN reading_sessions rs ON rs.filename = l.filename
|
||||
LEFT JOIN reading_progress rp ON rp.filename = l.filename
|
||||
WHERE COALESCE(l.series, '') = ''
|
||||
AND l.filename NOT LIKE '%/Series/%'
|
||||
AND l.archived = FALSE
|
||||
AND rs.id IS NULL
|
||||
AND COALESCE(rp.progress, 0) = 0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM book_tags bt
|
||||
WHERE bt.filename = l.filename
|
||||
AND bt.tag = 'Shorts'
|
||||
AND bt.tag_type IN ('tag', 'subject')
|
||||
)
|
||||
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type
|
||||
ORDER BY RANDOM()
|
||||
"""
|
||||
)
|
||||
shorts_rows = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type
|
||||
FROM library l
|
||||
LEFT JOIN reading_sessions rs ON rs.filename = l.filename
|
||||
LEFT JOIN reading_progress rp ON rp.filename = l.filename
|
||||
WHERE COALESCE(l.series, '') = ''
|
||||
AND l.filename NOT LIKE '%/Series/%'
|
||||
AND l.archived = FALSE
|
||||
AND rs.id IS NULL
|
||||
AND COALESCE(rp.progress, 0) = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM book_tags bt
|
||||
WHERE bt.filename = l.filename
|
||||
AND bt.tag = 'Shorts'
|
||||
AND bt.tag_type IN ('tag', 'subject')
|
||||
)
|
||||
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type
|
||||
ORDER BY RANDOM()
|
||||
"""
|
||||
)
|
||||
novels_rows = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type,
|
||||
MAX(rs.read_at) AS last_read
|
||||
FROM library l
|
||||
JOIN reading_sessions rs ON rs.filename = l.filename
|
||||
WHERE COALESCE(l.series, '') = ''
|
||||
AND l.filename NOT LIKE '%/Series/%'
|
||||
AND l.archived = FALSE
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM book_tags bt
|
||||
WHERE bt.filename = l.filename
|
||||
AND bt.tag = 'Shorts'
|
||||
AND bt.tag_type IN ('tag', 'subject')
|
||||
)
|
||||
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type
|
||||
ORDER BY MAX(rs.read_at) ASC
|
||||
"""
|
||||
)
|
||||
shorts_read_rows = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type,
|
||||
MAX(rs.read_at) AS last_read
|
||||
FROM library l
|
||||
JOIN reading_sessions rs ON rs.filename = l.filename
|
||||
WHERE COALESCE(l.series, '') = ''
|
||||
AND l.filename NOT LIKE '%/Series/%'
|
||||
AND l.archived = FALSE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM book_tags bt
|
||||
WHERE bt.filename = l.filename
|
||||
AND bt.tag = 'Shorts'
|
||||
AND bt.tag_type IN ('tag', 'subject')
|
||||
)
|
||||
GROUP BY l.filename, l.title, l.author, l.has_cover, l.publication_status, l.media_type
|
||||
ORDER BY MAX(rs.read_at) ASC
|
||||
"""
|
||||
)
|
||||
novels_read_rows = cur.fetchall()
|
||||
|
||||
def simple(rows):
|
||||
return [
|
||||
{
|
||||
"filename": r[0],
|
||||
"title": r[1] or "",
|
||||
"author": r[2] or "",
|
||||
"has_cover": bool(r[3]),
|
||||
"publication_status": r[4] or "",
|
||||
"media_type": r[5] or "epub",
|
||||
"progress": 0,
|
||||
"series": "",
|
||||
"series_index": 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def simple_read(rows):
|
||||
return [
|
||||
{
|
||||
"filename": r[0],
|
||||
"title": r[1] or "",
|
||||
"author": r[2] or "",
|
||||
"has_cover": bool(r[3]),
|
||||
"publication_status": r[4] or "",
|
||||
"media_type": r[5] or "epub",
|
||||
"last_read": r[6].isoformat() if r[6] else None,
|
||||
"progress": 0,
|
||||
"series": "",
|
||||
"series_index": 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
return {
|
||||
"continue_reading": [
|
||||
{
|
||||
"filename": r[0],
|
||||
"title": r[1] or "",
|
||||
"author": r[2] or "",
|
||||
"media_type": r[3],
|
||||
"progress": r[4] or 0,
|
||||
"last_read": r[5].isoformat() if r[5] else None,
|
||||
"has_cover": bool(r[3]),
|
||||
"series": r[4] or "",
|
||||
"series_index": r[5] or 0,
|
||||
"publication_status": r[6] or "",
|
||||
"media_type": r[7] or "epub",
|
||||
"progress": r[8] or 0,
|
||||
"progress_cfi": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
for r in cr_rows
|
||||
],
|
||||
"shorts_unread": simple(shorts_rows),
|
||||
"novels_unread": simple(novels_rows),
|
||||
"shorts_read": simple_read(shorts_read_rows),
|
||||
"novels_read": simple_read(novels_read_rows),
|
||||
}
|
||||
|
||||
|
||||
@ -357,10 +537,13 @@ async def api_stats():
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*)::int FROM library")
|
||||
total_books = cur.fetchone()[0]
|
||||
|
||||
cur.execute("SELECT COUNT(*)::int FROM reading_sessions")
|
||||
total_reads = cur.fetchone()[0]
|
||||
|
||||
cur.execute("SELECT COUNT(DISTINCT filename)::int FROM reading_sessions")
|
||||
unique_books_read = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT media_type, COUNT(*)::int
|
||||
@ -370,14 +553,143 @@ async def api_stats():
|
||||
"""
|
||||
)
|
||||
by_type = [{"media_type": r[0], "count": r[1]} for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
WITH months AS (
|
||||
SELECT date_trunc('month', CURRENT_DATE) - (n * interval '1 month') AS month_start
|
||||
FROM generate_series(11, 0, -1) AS n
|
||||
), counts AS (
|
||||
SELECT date_trunc('month', read_at) AS month_start, COUNT(*)::int AS cnt
|
||||
FROM reading_sessions
|
||||
WHERE read_at >= date_trunc('month', CURRENT_DATE) - interval '11 months'
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT to_char(m.month_start, 'YYYY-MM') AS month, COALESCE(c.cnt, 0)::int AS count
|
||||
FROM months m
|
||||
LEFT JOIN counts c ON c.month_start = m.month_start
|
||||
ORDER BY m.month_start
|
||||
"""
|
||||
)
|
||||
reads_by_month = [{"month": r[0], "count": r[1]} for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT EXTRACT(DOW FROM read_at)::int AS dow, COUNT(*)::int
|
||||
FROM reading_sessions
|
||||
GROUP BY 1
|
||||
"""
|
||||
)
|
||||
reads_by_dow = [0] * 7
|
||||
for dow, count in cur.fetchall():
|
||||
idx = (int(dow) + 6) % 7
|
||||
reads_by_dow[idx] = int(count)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT EXTRACT(HOUR FROM read_at)::int AS hour, COUNT(*)::int
|
||||
FROM reading_sessions
|
||||
GROUP BY 1
|
||||
"""
|
||||
)
|
||||
reads_by_hour = [0] * 24
|
||||
for hour, count in cur.fetchall():
|
||||
h = int(hour)
|
||||
if 0 <= h <= 23:
|
||||
reads_by_hour[h] = int(count)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT bt.tag AS name, COUNT(DISTINCT bt.filename)::int AS count
|
||||
FROM book_tags bt
|
||||
JOIN library l ON l.filename = bt.filename
|
||||
WHERE bt.tag_type IN ('genre', 'subgenre')
|
||||
GROUP BY bt.tag
|
||||
ORDER BY count DESC, name ASC
|
||||
"""
|
||||
)
|
||||
genre_counts = [{"name": r[0], "count": r[1]} for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT publisher AS name, COUNT(*)::int AS count
|
||||
FROM library
|
||||
WHERE COALESCE(TRIM(publisher), '') <> ''
|
||||
GROUP BY publisher
|
||||
ORDER BY count DESC, name ASC
|
||||
"""
|
||||
)
|
||||
publisher_counts = [{"name": r[0], "count": r[1]} for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(NULLIF(TRIM(l.title), ''), l.filename) AS title,
|
||||
COALESCE(l.author, '') AS author,
|
||||
COUNT(*)::int AS count
|
||||
FROM reading_sessions rs
|
||||
JOIN library l ON l.filename = rs.filename
|
||||
GROUP BY l.filename, l.title, l.author
|
||||
ORDER BY count DESC, MAX(rs.read_at) DESC
|
||||
LIMIT 10
|
||||
"""
|
||||
)
|
||||
top_books = [{"title": r[0], "author": r[1], "count": r[2]} for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(NULLIF(TRIM(l.title), ''), l.filename) AS title,
|
||||
COALESCE(l.author, '') AS author,
|
||||
COALESCE(l.publisher, '') AS publisher,
|
||||
rs.read_at,
|
||||
COALESCE(
|
||||
array_remove(
|
||||
array_agg(DISTINCT CASE WHEN bt.tag_type IN ('genre', 'subgenre') THEN bt.tag END),
|
||||
NULL
|
||||
),
|
||||
ARRAY[]::text[]
|
||||
) AS genres
|
||||
FROM reading_sessions rs
|
||||
JOIN library l ON l.filename = rs.filename
|
||||
LEFT JOIN book_tags bt ON bt.filename = l.filename
|
||||
GROUP BY rs.id, l.filename, l.title, l.author, l.publisher, rs.read_at
|
||||
ORDER BY rs.read_at DESC
|
||||
LIMIT 50
|
||||
"""
|
||||
)
|
||||
history = [
|
||||
{
|
||||
"title": r[0],
|
||||
"author": r[1],
|
||||
"publisher": r[2],
|
||||
"read_at": r[3].isoformat() if r[3] else None,
|
||||
"genres": list(r[4] or []),
|
||||
}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
fav_genre = genre_counts[0]["name"] if genre_counts else None
|
||||
fav_publisher = publisher_counts[0]["name"] if publisher_counts else None
|
||||
|
||||
return {
|
||||
"total_books": total_books,
|
||||
"total_reads": total_reads,
|
||||
"unique_books_read": unique_books_read,
|
||||
"by_media_type": by_type,
|
||||
"reads_by_month": reads_by_month,
|
||||
"reads_by_dow": reads_by_dow,
|
||||
"reads_by_hour": reads_by_hour,
|
||||
"genre_counts": genre_counts,
|
||||
"publisher_counts": publisher_counts,
|
||||
"fav_genre": fav_genre,
|
||||
"fav_publisher": fav_publisher,
|
||||
"top_books": top_books,
|
||||
"history": history,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/library/list")
|
||||
async def library_list_compat():
|
||||
return await api_library()
|
||||
|
||||
@ -454,3 +454,186 @@ html, body {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.8rem;
|
||||
}
|
||||
|
||||
/* ── New view controls + list mode ─────────────────────────────────────── */
|
||||
|
||||
.new-controls {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.new-controls-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(34, 31, 27, 0.5);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.55rem 0.6rem;
|
||||
}
|
||||
|
||||
.new-view-toggle {
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.new-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.btn.btn-view,
|
||||
.btn.btn-light,
|
||||
.btn.btn-mark-reviewed {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.btn.btn-view.active {
|
||||
border-color: rgba(200, 120, 58, 0.45);
|
||||
background: rgba(200, 120, 58, 0.16);
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.btn.btn-light:hover,
|
||||
.btn.btn-view:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn.btn-mark-reviewed {
|
||||
border-color: rgba(107, 170, 107, 0.35);
|
||||
background: rgba(107, 170, 107, 0.14);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.btn.btn-mark-reviewed:hover {
|
||||
background: rgba(107, 170, 107, 0.24);
|
||||
}
|
||||
|
||||
.btn.btn-mark-reviewed:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.new-selection-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
|
||||
.new-columns-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
min-width: 190px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.new-columns-menu.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.new-col-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.3rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.new-col-item:hover {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.new-list-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(34, 31, 27, 0.48);
|
||||
}
|
||||
|
||||
.new-list-table {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.new-list-table thead th {
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.5rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.63rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(15, 14, 12, 0.35);
|
||||
}
|
||||
|
||||
.new-list-table tbody td {
|
||||
padding: 0.52rem 0.5rem;
|
||||
border-bottom: 1px solid rgba(46, 42, 36, 0.55);
|
||||
font-size: 0.74rem;
|
||||
color: var(--text);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.new-list-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-list-table tbody tr:hover {
|
||||
background: rgba(200, 120, 58, 0.08);
|
||||
}
|
||||
|
||||
.new-col-select {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.new-list-table .col-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.new-list-table .col-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.main {
|
||||
padding: 1.2rem 1rem 2rem;
|
||||
}
|
||||
|
||||
.new-controls-bar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.new-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.new-columns-menu {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,28 @@ let coverB64 = null;
|
||||
let importInProgress = false;
|
||||
const MISSING_PUBLISHER_KEY = '__missing__';
|
||||
const MISSING_PUBLISHER_LABEL = 'No publisher';
|
||||
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
|
||||
const NEW_VIEW_MODE_KEY = 'novela.new.viewMode';
|
||||
const NEW_VISIBLE_COLUMNS_KEY = 'novela.new.visibleColumns';
|
||||
const NEW_DEFAULT_COLUMNS = ['publisher', 'author', 'series', 'volume', 'title', 'has_cover', 'updated', 'genres', 'subgenres', 'tags', 'status'];
|
||||
const NEW_COLUMN_DEFS = [
|
||||
{ id: 'publisher', label: 'Publisher' },
|
||||
{ id: 'author', label: 'Author' },
|
||||
{ id: 'series', label: 'Series' },
|
||||
{ id: 'volume', label: 'Volume' },
|
||||
{ id: 'title', label: 'Title' },
|
||||
{ id: 'has_cover', label: 'Has cover' },
|
||||
{ id: 'updated', label: 'Updated' },
|
||||
{ id: 'genres', label: 'Genres' },
|
||||
{ id: 'subgenres', label: 'Sub-genres' },
|
||||
{ id: 'tags', label: 'Tags' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
];
|
||||
|
||||
let newViewMode = loadNewViewMode();
|
||||
let newVisibleColumns = loadNewVisibleColumns();
|
||||
let newSelectedFilenames = new Set();
|
||||
let newLastToggledIndex = null;
|
||||
|
||||
|
||||
// ── Placeholder cover generation ───────────────────────────────────────────
|
||||
@ -108,14 +130,19 @@ function updateCounts() {
|
||||
if (archEl) archEl.textContent = archCount || '';
|
||||
}
|
||||
|
||||
function _filenameBase(filename) {
|
||||
const leaf = String(filename || '').split('/').pop() || '';
|
||||
return leaf.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
function bookAuthor(b) {
|
||||
if (b.author) return b.author;
|
||||
const parts = b.filename.replace(/\.epub$/, '').split('-');
|
||||
const parts = _filenameBase(b.filename).split('-');
|
||||
return (parts[1] ?? '').replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function bookTitle(b) {
|
||||
return b.title || (b.filename.replace(/\.epub$/, '').split('-')[2] ?? '').replace(/_/g, ' ');
|
||||
return b.title || (_filenameBase(b.filename).split('-')[2] ?? '').replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function normalizePublisherName(value) {
|
||||
@ -189,6 +216,11 @@ function _applyView(view, param) {
|
||||
view === 'genre' ? `Genre: ${param || ''}` :
|
||||
view === 'search' ? `Search: "${param || ''}"` : '';
|
||||
|
||||
if (view !== 'new') {
|
||||
newSelectedFilenames.clear();
|
||||
newLastToggledIndex = null;
|
||||
}
|
||||
|
||||
const showBack = view === 'series-detail' || view === 'author-detail' || view === 'publisher-detail';
|
||||
document.getElementById('back-btn').style.display = showBack ? '' : 'none';
|
||||
|
||||
@ -211,6 +243,7 @@ window.addEventListener('popstate', e => {
|
||||
|
||||
function renderGrid() {
|
||||
const active = activeBooks();
|
||||
if (currentView !== 'new') hideNewControls();
|
||||
if (currentView === 'all') renderBooksGrid(active);
|
||||
else if (currentView === 'wtr') renderBooksGrid(active.filter(b => b.want_to_read));
|
||||
else if (currentView === 'series') renderSeriesGrid();
|
||||
@ -220,11 +253,326 @@ function renderGrid() {
|
||||
else if (currentView === 'publishers') renderPublishersView();
|
||||
else if (currentView === 'publisher-detail') renderPublisherDetail(currentParam);
|
||||
else if (currentView === 'archived') renderBooksGrid(archivedBooks());
|
||||
else if (currentView === 'new') renderBooksGrid(active.filter(b => b.needs_review));
|
||||
else if (currentView === 'new') renderNewBooksView(active.filter(b => b.needs_review));
|
||||
else if (currentView === 'genre') renderGenreView(currentParam);
|
||||
else if (currentView === 'search') renderSearchResults(currentParam);
|
||||
}
|
||||
|
||||
// ── New view (bulk review + list/grid toggle) ─────────────────────────────
|
||||
|
||||
function loadNewViewMode() {
|
||||
try {
|
||||
const raw = localStorage.getItem(NEW_VIEW_MODE_KEY);
|
||||
return raw === 'list' ? 'list' : 'grid';
|
||||
} catch {
|
||||
return 'grid';
|
||||
}
|
||||
}
|
||||
|
||||
function loadNewVisibleColumns() {
|
||||
try {
|
||||
const raw = localStorage.getItem(NEW_VISIBLE_COLUMNS_KEY);
|
||||
if (!raw) return [...NEW_DEFAULT_COLUMNS];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [...NEW_DEFAULT_COLUMNS];
|
||||
const allowed = new Set(NEW_COLUMN_DEFS.map(c => c.id));
|
||||
const saved = new Set(parsed.filter(v => typeof v === 'string' && allowed.has(v)));
|
||||
const normalized = NEW_COLUMN_DEFS.map(c => c.id).filter(id => saved.has(id));
|
||||
if (!normalized.length) return [...NEW_DEFAULT_COLUMNS];
|
||||
return normalized;
|
||||
} catch {
|
||||
return [...NEW_DEFAULT_COLUMNS];
|
||||
}
|
||||
}
|
||||
|
||||
function persistNewColumns() {
|
||||
try {
|
||||
localStorage.setItem(NEW_VISIBLE_COLUMNS_KEY, JSON.stringify(newVisibleColumns));
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
function persistNewViewMode() {
|
||||
try {
|
||||
localStorage.setItem(NEW_VIEW_MODE_KEY, newViewMode);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
function hideNewControls() {
|
||||
const controls = document.getElementById('new-controls');
|
||||
if (!controls) return;
|
||||
controls.style.display = 'none';
|
||||
controls.innerHTML = '';
|
||||
}
|
||||
|
||||
function setNewViewMode(mode) {
|
||||
if (mode !== 'grid' && mode !== 'list') return;
|
||||
newViewMode = mode;
|
||||
if (mode === 'grid') {
|
||||
newSelectedFilenames.clear();
|
||||
newLastToggledIndex = null;
|
||||
}
|
||||
persistNewViewMode();
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function toggleNewColumnsMenu(ev) {
|
||||
ev?.stopPropagation();
|
||||
const menu = document.getElementById('new-columns-menu');
|
||||
if (!menu) return;
|
||||
menu.classList.toggle('visible');
|
||||
}
|
||||
|
||||
function toggleNewColumn(columnId) {
|
||||
const set = new Set(newVisibleColumns);
|
||||
if (set.has(columnId)) set.delete(columnId);
|
||||
else set.add(columnId);
|
||||
|
||||
const ordered = NEW_COLUMN_DEFS.map(c => c.id).filter(id => set.has(id));
|
||||
newVisibleColumns = ordered.length ? ordered : ['title'];
|
||||
persistNewColumns();
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function toggleSelectAllNewRows(checked, books) {
|
||||
if (checked) {
|
||||
books.forEach(b => newSelectedFilenames.add(b.filename));
|
||||
newLastToggledIndex = books.length ? books.length - 1 : null;
|
||||
} else {
|
||||
books.forEach(b => newSelectedFilenames.delete(b.filename));
|
||||
newLastToggledIndex = null;
|
||||
}
|
||||
renderNewControls(books);
|
||||
if (newViewMode === 'list') {
|
||||
const rowChecks = document.querySelectorAll('.new-row-select');
|
||||
rowChecks.forEach(cb => { cb.checked = checked; });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNewRowWithShift(filename, checked, shiftPressed) {
|
||||
const books = activeBooks().filter(b => b.needs_review);
|
||||
const filenames = books.map(b => b.filename);
|
||||
const idx = filenames.indexOf(filename);
|
||||
if (idx === -1) return;
|
||||
|
||||
const doRange = !!(shiftPressed && newLastToggledIndex !== null);
|
||||
if (doRange) {
|
||||
const start = Math.min(newLastToggledIndex, idx);
|
||||
const end = Math.max(newLastToggledIndex, idx);
|
||||
for (let i = start; i <= end; i++) {
|
||||
const name = filenames[i];
|
||||
if (checked) newSelectedFilenames.add(name);
|
||||
else newSelectedFilenames.delete(name);
|
||||
}
|
||||
} else {
|
||||
if (checked) newSelectedFilenames.add(filename);
|
||||
else newSelectedFilenames.delete(filename);
|
||||
}
|
||||
|
||||
newLastToggledIndex = idx;
|
||||
renderNewControls(books);
|
||||
renderNewBooksList(books);
|
||||
}
|
||||
|
||||
function handleNewRowCheckboxClick(filename, checkboxEl, ev) {
|
||||
ev?.stopPropagation();
|
||||
const shiftPressed = !!(ev && ev.shiftKey);
|
||||
toggleNewRowWithShift(filename, !!checkboxEl?.checked, shiftPressed);
|
||||
}
|
||||
|
||||
function clearNewSelection(books) {
|
||||
books.forEach(b => newSelectedFilenames.delete(b.filename));
|
||||
newLastToggledIndex = null;
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
async function markSelectedNewAsReviewed(books) {
|
||||
const selected = books.filter(b => newSelectedFilenames.has(b.filename)).map(b => b.filename);
|
||||
if (!selected.length) return;
|
||||
|
||||
const btn = document.getElementById('btn-mark-reviewed');
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/library/new/mark-reviewed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filenames: selected }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || result.error) {
|
||||
alert(result.error || 'Could not mark books as reviewed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedSet = new Set(selected);
|
||||
allBooks.forEach(b => {
|
||||
if (selectedSet.has(b.filename)) b.needs_review = false;
|
||||
});
|
||||
selected.forEach(f => newSelectedFilenames.delete(f));
|
||||
updateCounts();
|
||||
renderGrid();
|
||||
} catch {
|
||||
alert('Could not mark books as reviewed.');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function tagValuesByType(book, type) {
|
||||
return (book.tags || [])
|
||||
.filter(t => t && t.tag_type === type && t.tag)
|
||||
.map(t => t.tag);
|
||||
}
|
||||
|
||||
function bookGenres(book) {
|
||||
const explicit = tagValuesByType(book, 'genre');
|
||||
if (explicit.length) return explicit;
|
||||
return (book.tags || [])
|
||||
.filter(t => t && t.tag_type === 'subject' && t.tag)
|
||||
.map(t => t.tag);
|
||||
}
|
||||
|
||||
function bookSubgenres(book) {
|
||||
return tagValuesByType(book, 'subgenre');
|
||||
}
|
||||
|
||||
function bookPlainTags(book) {
|
||||
return tagValuesByType(book, 'tag');
|
||||
}
|
||||
|
||||
function formatUpdated(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function newCellText(book, colId) {
|
||||
if (colId === 'publisher') return publisherDisplayName(bookPublisherKey(book));
|
||||
if (colId === 'author') return bookAuthor(book);
|
||||
if (colId === 'series') return book.series || '';
|
||||
if (colId === 'title') return bookTitle(book);
|
||||
if (colId === 'has_cover') return book.has_cover ? 'Yes' : 'No';
|
||||
if (colId === 'updated') return formatUpdated(book.updated_at);
|
||||
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 === 'status') return book.publication_status || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderNewControls(books) {
|
||||
const controls = document.getElementById('new-controls');
|
||||
if (!controls) return;
|
||||
if (currentView !== 'new') {
|
||||
hideNewControls();
|
||||
return;
|
||||
}
|
||||
|
||||
const validFilenames = new Set(books.map(b => b.filename));
|
||||
newSelectedFilenames.forEach(filename => {
|
||||
if (!validFilenames.has(filename)) newSelectedFilenames.delete(filename);
|
||||
});
|
||||
|
||||
const listMode = newViewMode === 'list';
|
||||
const selectedCount = listMode
|
||||
? books.filter(b => newSelectedFilenames.has(b.filename)).length
|
||||
: 0;
|
||||
const allSelected = listMode && !!books.length && selectedCount === books.length;
|
||||
|
||||
controls.style.display = '';
|
||||
controls.innerHTML = `
|
||||
<div class="new-controls-bar">
|
||||
<div class="new-view-toggle">
|
||||
<button class="btn btn-view ${newViewMode === 'grid' ? 'active' : ''}" onclick="setNewViewMode('grid')">Grid</button>
|
||||
<button class="btn btn-view ${newViewMode === 'list' ? 'active' : ''}" onclick="setNewViewMode('list')">List</button>
|
||||
</div>
|
||||
<div class="new-actions">
|
||||
${listMode ? `
|
||||
<button class="btn btn-light" onclick="toggleNewColumnsMenu(event)">Columns</button>
|
||||
<div class="new-columns-menu" id="new-columns-menu">
|
||||
${NEW_COLUMN_DEFS.map(col => `
|
||||
<label class="new-col-item">
|
||||
<input type="checkbox" ${newVisibleColumns.includes(col.id) ? 'checked' : ''} onchange="toggleNewColumn('${col.id}')"/>
|
||||
<span>${esc(col.label)}</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
<span class="new-selection-count">${selectedCount} selected</span>
|
||||
<button class="btn btn-light" onclick="toggleSelectAllNewRows(${allSelected ? 'false' : 'true'}, activeBooks().filter(b => b.needs_review))">${allSelected ? 'Clear all' : 'Select all'}</button>
|
||||
<button class="btn btn-light" onclick="clearNewSelection(activeBooks().filter(b => b.needs_review))">Clear selection</button>
|
||||
<button class="btn btn-mark-reviewed" id="btn-mark-reviewed" onclick="markSelectedNewAsReviewed(activeBooks().filter(b => b.needs_review))" ${selectedCount ? '' : 'disabled'}>
|
||||
Remove from New
|
||||
</button>
|
||||
` : `
|
||||
<span class="new-selection-count">Switch to List to select multiple books</span>
|
||||
`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderNewBooksList(books) {
|
||||
const container = document.getElementById('grid-container');
|
||||
if (!books.length) {
|
||||
container.innerHTML = '<div class="empty">No newly imported books waiting for metadata review.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cols = NEW_COLUMN_DEFS.filter(c => newVisibleColumns.includes(c.id));
|
||||
const selectedCount = books.filter(b => newSelectedFilenames.has(b.filename)).length;
|
||||
const allSelected = 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="toggleSelectAllNewRows(this.checked, activeBooks().filter(b => b.needs_review))"/></th>
|
||||
${cols.map(c => `<th>${esc(c.label)}</th>`).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${books.map(b => `
|
||||
<tr class="new-list-row" data-filename="${esc(b.filename)}">
|
||||
<td class="new-col-select"><input class="new-row-select" type="checkbox" ${newSelectedFilenames.has(b.filename) ? 'checked' : ''} onclick="handleNewRowCheckboxClick('${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>`;
|
||||
if (c.id === 'has_cover') return `<td class="col-center">${esc(value)}</td>`;
|
||||
return `<td>${esc(value)}</td>`;
|
||||
}).join('')}
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
container.querySelectorAll('.new-list-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const filename = row.getAttribute('data-filename') || '';
|
||||
if (!filename) return;
|
||||
location.href = `/library/book/${encodeURIComponent(filename)}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderNewBooksView(books) {
|
||||
renderNewControls(books);
|
||||
if (newViewMode === 'list') {
|
||||
renderNewBooksList(books);
|
||||
return;
|
||||
}
|
||||
renderBooksGrid(books);
|
||||
}
|
||||
|
||||
// ── Book grid (All / WTR / Author detail) ─────────────────────────────────
|
||||
|
||||
function renderBooksGrid(books) {
|
||||
@ -237,7 +585,7 @@ function renderBooksGrid(books) {
|
||||
currentView === 'new' ? 'No newly imported books waiting for metadata review.' :
|
||||
currentView === 'genre' ? `No books tagged "${esc(currentParam || '')}".` :
|
||||
currentView === 'search' ? `No results for "${esc(currentParam || '')}".` :
|
||||
'No EPUBs yet. Convert a story first.'
|
||||
'No books yet. Import EPUB, PDF or CBR/CBZ to get started.'
|
||||
}</div>`;
|
||||
return;
|
||||
}
|
||||
@ -855,7 +1203,9 @@ function openImportPicker() {
|
||||
|
||||
function onImportFilesSelected(fileList) {
|
||||
if (!fileList || !fileList.length) return;
|
||||
uploadImportedFiles(Array.from(fileList));
|
||||
const files = Array.from(fileList).filter(f => IMPORT_EXTENSIONS.some(ext => f.name.toLowerCase().endsWith(ext)));
|
||||
if (!files.length) return;
|
||||
uploadImportedFiles(files);
|
||||
const input = document.getElementById('import-file-input');
|
||||
if (input) input.value = '';
|
||||
}
|
||||
@ -868,7 +1218,7 @@ async function uploadImportedFiles(files) {
|
||||
|
||||
importInProgress = true;
|
||||
zone?.classList.add('uploading');
|
||||
if (title) title.textContent = 'Importing EPUBs…';
|
||||
if (title) title.textContent = 'Importing files…';
|
||||
if (sub) sub.textContent = `${files.length} file(s) selected`;
|
||||
|
||||
const form = new FormData();
|
||||
@ -883,8 +1233,8 @@ async function uploadImportedFiles(files) {
|
||||
const importedCount = (data.imported || []).length;
|
||||
const skippedCount = (data.skipped || []).length;
|
||||
if (title) title.textContent = importedCount
|
||||
? `Imported ${importedCount} EPUB(s)`
|
||||
: 'No EPUBs imported';
|
||||
? `Imported ${importedCount} file(s)`
|
||||
: 'No files imported';
|
||||
if (sub) sub.textContent = skippedCount
|
||||
? `${skippedCount} skipped`
|
||||
: 'Ready for next import';
|
||||
@ -896,7 +1246,7 @@ async function uploadImportedFiles(files) {
|
||||
importInProgress = false;
|
||||
zone?.classList.remove('uploading');
|
||||
setTimeout(() => {
|
||||
if (title) title.textContent = 'Drop EPUB files here';
|
||||
if (title) title.textContent = 'Drop EPUB, PDF or CBR/CBZ files here';
|
||||
if (sub) sub.textContent = 'or click to choose files';
|
||||
}, 1200);
|
||||
}
|
||||
@ -954,12 +1304,20 @@ if (importZone) {
|
||||
});
|
||||
importZone.addEventListener('drop', e => {
|
||||
if (importInProgress) return;
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter(f => f.name.toLowerCase().endsWith('.epub'));
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter(f => IMPORT_EXTENSIONS.some(ext => f.name.toLowerCase().endsWith(ext)));
|
||||
if (!files.length) return;
|
||||
uploadImportedFiles(files);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const menu = document.getElementById('new-columns-menu');
|
||||
if (!menu) return;
|
||||
const toggleBtn = e.target && e.target.closest ? e.target.closest('.new-actions .btn-light') : null;
|
||||
if (menu.contains(e.target) || toggleBtn) return;
|
||||
menu.classList.remove('visible');
|
||||
});
|
||||
|
||||
loadLibrary().then(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
let view = 'all', param = null;
|
||||
|
||||
@ -90,6 +90,25 @@
|
||||
.btn.primary { border-color: rgba(200,120,58,0.45); background: rgba(200,120,58,0.12); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.field-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.status-line { margin-top: 0.7rem; font-family: var(--mono); font-size: 0.74rem; }
|
||||
.ok { color: var(--ok); }
|
||||
.warn { color: var(--warn); }
|
||||
@ -114,6 +133,29 @@
|
||||
<main class="main">
|
||||
<div class="title">Backup</div>
|
||||
|
||||
<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"/>
|
||||
<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>
|
||||
<input class="field-input" id="retention-count" type="number" min="1" step="1" value="14" autocomplete="off"/>
|
||||
<label class="field-label" for="schedule-enabled">Schedule Enabled</label>
|
||||
<select class="field-input" id="schedule-enabled">
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true">Enabled</option>
|
||||
</select>
|
||||
<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" onclick="clearDropboxToken()">Remove Token</button>
|
||||
</div>
|
||||
<div class="status-line" id="dropbox-status"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">Run</div>
|
||||
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
|
||||
@ -182,6 +224,9 @@
|
||||
rowHtml('Dropbox token', d.token_present ? 'present' : 'missing'),
|
||||
rowHtml('Dropbox auth', fmtStatus(d.dropbox_ok)),
|
||||
rowHtml('Dropbox error', d.dropbox_error || '-'),
|
||||
rowHtml('Dropbox root', d.dropbox_root || '/novela'),
|
||||
rowHtml('Snapshots keep', d.retention_count ?? 14),
|
||||
rowHtml('Schedule', d.schedule_enabled ? `enabled (${d.schedule_interval_hours || 24}h)` : 'disabled'),
|
||||
rowHtml('pg_dump', d.pg_dump_available ? (d.pg_dump_path || 'available') : 'missing'),
|
||||
rowHtml('Library exists', fmtStatus(d.library_exists)),
|
||||
rowHtml('Library path', d.library_path || '-'),
|
||||
@ -230,6 +275,100 @@
|
||||
`).join('');
|
||||
}
|
||||
|
||||
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');
|
||||
const scheduleHoursEl = document.getElementById('schedule-hours');
|
||||
out.className = 'status-line';
|
||||
out.textContent = 'Loading Dropbox settings...';
|
||||
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}` : ''}`;
|
||||
} else {
|
||||
out.className = 'status-line warn';
|
||||
out.textContent = 'No Dropbox token configured.';
|
||||
}
|
||||
} catch (e) {
|
||||
out.className = 'status-line err';
|
||||
out.textContent = `Failed to load settings: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
const scheduleIntervalHours = Math.max(1, parseInt((document.getElementById('schedule-hours').value || '24').trim(), 10) || 24);
|
||||
out.className = 'status-line warn';
|
||||
out.textContent = 'Saving backup settings...';
|
||||
try {
|
||||
const r = await fetch('/api/backup/credentials', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
dropbox_root: dropboxRoot,
|
||||
retention_count: retentionCount,
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_interval_hours: scheduleIntervalHours
|
||||
}),
|
||||
});
|
||||
const raw = await r.text();
|
||||
let d;
|
||||
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)`;
|
||||
await Promise.all([loadDropboxSettings(), loadHealth()]);
|
||||
} catch (e) {
|
||||
out.className = 'status-line err';
|
||||
out.textContent = `Save failed: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDropboxToken() {
|
||||
if (!confirm('Remove Dropbox token for backup?')) return;
|
||||
const out = document.getElementById('dropbox-status');
|
||||
out.className = 'status-line warn';
|
||||
out.textContent = 'Removing token...';
|
||||
try {
|
||||
await fetch('/api/backup/credentials', {method: 'DELETE'});
|
||||
out.className = 'status-line ok';
|
||||
out.textContent = 'Dropbox token removed.';
|
||||
document.getElementById('dropbox-token').value = '';
|
||||
document.getElementById('dropbox-root').value = '/novela';
|
||||
document.getElementById('retention-count').value = 14;
|
||||
document.getElementById('schedule-enabled').value = 'false';
|
||||
document.getElementById('schedule-hours').value = 24;
|
||||
await Promise.all([loadDropboxSettings(), loadHealth()]);
|
||||
} catch (e) {
|
||||
out.className = 'status-line err';
|
||||
out.textContent = `Remove failed: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
@ -249,7 +388,11 @@
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
out.className = 'status-line ok';
|
||||
if (d.status === 'running') {
|
||||
out.textContent = `Backup started in background. id=${d.backup_id}, dry_run=${d.dry_run}`;
|
||||
} else {
|
||||
out.textContent = `Backup ${d.status}. id=${d.backup_id}, files=${d.files_count}, bytes=${d.size_bytes}, dry_run=${d.dry_run}`;
|
||||
}
|
||||
} else {
|
||||
out.className = 'status-line err';
|
||||
out.textContent = `Backup failed: ${d.error || 'unknown error'}`;
|
||||
@ -265,7 +408,7 @@
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([loadHealth(), loadStatus(), loadHistory()]);
|
||||
await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory()]);
|
||||
}
|
||||
|
||||
refreshAll();
|
||||
|
||||
@ -21,7 +21,71 @@
|
||||
|
||||
.main { margin-left: var(--sidebar); min-height: 100vh; padding: 2rem 2.5rem 4rem; }
|
||||
|
||||
/* ── Section header ──────────────────────────────────────────────── */
|
||||
.main-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.main-title {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-icon { position: absolute; left: 0.5rem; color: var(--text-faint); pointer-events: none; }
|
||||
.search-input {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); color: var(--text);
|
||||
font-family: var(--mono); font-size: 0.78rem;
|
||||
padding: 0.4rem 1.8rem 0.4rem 2rem;
|
||||
outline: none; width: 220px;
|
||||
transition: border-color 0.15s, width 0.2s;
|
||||
}
|
||||
.search-input:focus { border-color: var(--accent); width: 280px; }
|
||||
.search-input::placeholder { color: var(--text-faint); }
|
||||
.search-clear {
|
||||
position: absolute; right: 0.4rem;
|
||||
background: none; border: none; color: var(--text-faint);
|
||||
cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 0.1rem;
|
||||
}
|
||||
.search-clear:hover { color: var(--text-dim); }
|
||||
|
||||
.import-dropzone {
|
||||
border: 1px dashed var(--border);
|
||||
background: rgba(34, 31, 27, 0.45);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.import-dropzone:hover { border-color: var(--accent); }
|
||||
.import-dropzone.dragover {
|
||||
border-color: var(--accent2);
|
||||
background: rgba(200, 120, 58, 0.12);
|
||||
}
|
||||
.import-dropzone.uploading {
|
||||
opacity: 0.8;
|
||||
cursor: progress;
|
||||
}
|
||||
.import-title {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent2);
|
||||
}
|
||||
.import-sub {
|
||||
margin-top: 0.25rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.section-block { margin-bottom: 2.5rem; }
|
||||
.section-header {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
@ -40,7 +104,6 @@
|
||||
}
|
||||
.section-more:hover { color: var(--accent); }
|
||||
|
||||
/* ── Horizontal scroll row ───────────────────────────────────────── */
|
||||
.h-row { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: 0.75rem; }
|
||||
.h-row::-webkit-scrollbar { height: 4px; }
|
||||
.h-row::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
@ -71,7 +134,6 @@
|
||||
.h-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; }
|
||||
.h-pct { font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim); }
|
||||
|
||||
/* ── Full grid ───────────────────────────────────────────────────── */
|
||||
.grid-header {
|
||||
display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.75rem;
|
||||
}
|
||||
@ -119,10 +181,26 @@
|
||||
font-size: 0.82rem; padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.main { margin-left: 0; padding: 4rem 1rem 4rem; }
|
||||
.cover-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 1rem; }
|
||||
.main {
|
||||
margin-left: 0;
|
||||
padding: 4rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.cover-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-input { width: 100%; }
|
||||
.search-input:focus { width: 100%; }
|
||||
.search-wrap { flex: 1; min-width: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@ -131,8 +209,23 @@
|
||||
{% include "_sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<div class="main-header">
|
||||
<div class="main-title">Home</div>
|
||||
<div class="search-wrap">
|
||||
<svg class="search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<input type="text" id="home-search-input" class="search-input" placeholder="Search title, author, genre..." autocomplete="off"/>
|
||||
<button id="home-search-clear" class="search-clear" style="display:none" onclick="clearSearch()" title="Clear search">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-dropzone" id="import-dropzone" onclick="openImportPicker()">
|
||||
<input type="file" id="import-file-input" accept=".epub,.pdf,.cbr,.cbz,application/epub+zip,application/pdf" multiple style="display:none" onchange="onImportFilesSelected(this.files)"/>
|
||||
<div class="import-title">Drop EPUB, PDF or CBR/CBZ files here</div>
|
||||
<div class="import-sub">or click to choose files</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Home view: three horizontal rows ─────────────────────────────── -->
|
||||
<div id="home-view">
|
||||
<div class="section-block" id="cr-section" style="display:none">
|
||||
<div class="section-header">
|
||||
@ -174,10 +267,9 @@
|
||||
<div class="h-row" id="novels-read-row"></div>
|
||||
</div>
|
||||
|
||||
<div class="empty" id="home-empty" style="display:none">Nothing here yet — convert some books to get started.</div>
|
||||
<div class="empty" id="home-empty" style="display:none">Nothing here yet - import some books to get started.</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Grid view: see-all ────────────────────────────────────────────── -->
|
||||
<div id="grid-view" style="display:none">
|
||||
<div class="grid-header">
|
||||
<button class="btn-back" onclick="switchView('home')">
|
||||
@ -188,14 +280,15 @@
|
||||
</div>
|
||||
<div class="cover-grid" id="grid-container"></div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let data = { continue_reading: [], shorts_unread: [], novels_unread: [], shorts_read: [], novels_read: [] };
|
||||
let currentView = 'home';
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||
let importInProgress = false;
|
||||
let searchTimer = null;
|
||||
let allBooks = [];
|
||||
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
|
||||
|
||||
function strHash(s) {
|
||||
let h = 0;
|
||||
@ -206,6 +299,7 @@
|
||||
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],['#2a1a2a','#8a4aaa'],
|
||||
['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
|
||||
];
|
||||
|
||||
function makePlaceholder(canvas, title, author) {
|
||||
const w = canvas.width = canvas.offsetWidth || 150;
|
||||
const h = canvas.height = canvas.offsetHeight || 225;
|
||||
@ -221,6 +315,7 @@
|
||||
ctx.fillStyle = fg; ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
|
||||
ctx.globalAlpha = 0.85; ctx.fillText(trunc(author, 18), w * 0.55, h * 0.86); ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function wrapText(ctx, text, x, y, maxW, lineH) {
|
||||
const words = text.split(' '); let line = '', lines = [];
|
||||
for (const w of words) {
|
||||
@ -231,24 +326,27 @@
|
||||
const startY = y - ((lines.length - 1) * lineH) / 2;
|
||||
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
|
||||
}
|
||||
|
||||
function trunc(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
||||
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function cssId(s) { return s.replace(/[^a-zA-Z0-9]/g, '_'); }
|
||||
function bookTitle(b) { return b.title || (b.filename.replace(/\.epub$/, '').split('-')[2] ?? '').replace(/_/g, ' '); }
|
||||
function esc(s) { return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); }
|
||||
function filenameBase(filename) {
|
||||
const leaf = String(filename || '').split('/').pop() || '';
|
||||
return leaf.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
function bookTitle(b) { return b.title || (filenameBase(b.filename).split('-')[2] ?? '').replace(/_/g, ' '); }
|
||||
function bookAuthor(b) {
|
||||
if (b.author) return b.author;
|
||||
return (b.filename.replace(/\.epub$/, '').split('-')[1] ?? '').replace(/_/g, ' ');
|
||||
return (filenameBase(b.filename).split('-')[1] ?? '').replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
// ── Cover helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function attachCover(coverEl, canvasEl, b) {
|
||||
const title = bookTitle(b);
|
||||
const author = bookAuthor(b);
|
||||
if (b.has_cover) {
|
||||
const img = document.createElement('img');
|
||||
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
||||
img.src = `/library/cover/${encodeURIComponent(b.filename)}`;
|
||||
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
||||
img.alt = title;
|
||||
img.onload = () => { canvasEl.style.display = 'none'; };
|
||||
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvasEl, title, author));
|
||||
@ -258,8 +356,6 @@
|
||||
requestAnimationFrame(() => makePlaceholder(canvasEl, title, author));
|
||||
}
|
||||
|
||||
// ── Horizontal row card ───────────────────────────────────────────────────
|
||||
|
||||
function makeHCard(b, showProgress) {
|
||||
const title = bookTitle(b);
|
||||
const author = bookAuthor(b);
|
||||
@ -282,8 +378,6 @@
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Grid card (full grid view) ────────────────────────────────────────────
|
||||
|
||||
function makeGridCard(b) {
|
||||
const title = bookTitle(b);
|
||||
const author = bookAuthor(b);
|
||||
@ -306,8 +400,6 @@
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Render rows ───────────────────────────────────────────────────────────
|
||||
|
||||
function renderRow(rowEl, books, showProgress) {
|
||||
rowEl.innerHTML = '';
|
||||
books.slice(0, 20).forEach(b => {
|
||||
@ -323,6 +415,10 @@
|
||||
function renderGrid(books) {
|
||||
const container = document.getElementById('grid-container');
|
||||
container.innerHTML = '';
|
||||
if (!books.length) {
|
||||
container.innerHTML = '<div class="empty">No books found.</div>';
|
||||
return;
|
||||
}
|
||||
books.forEach(b => {
|
||||
const id = cssId(b.filename);
|
||||
const card = makeGridCard(b);
|
||||
@ -331,7 +427,7 @@
|
||||
if (b.has_cover) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'cover-img';
|
||||
img.src = `/library/cover/${encodeURIComponent(b.filename)}`;
|
||||
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
||||
img.alt = bookTitle(b);
|
||||
img.onload = () => { canvas.style.display = 'none'; };
|
||||
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b)));
|
||||
@ -342,12 +438,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── View switching ────────────────────────────────────────────────────────
|
||||
function searchResults(query) {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return allBooks.filter(b =>
|
||||
bookTitle(b).toLowerCase().includes(q) ||
|
||||
bookAuthor(b).toLowerCase().includes(q) ||
|
||||
(b.genres || []).some(g => String(g || '').toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
function switchView(view) {
|
||||
currentView = view;
|
||||
const homeView = document.getElementById('home-view');
|
||||
const gridView = document.getElementById('grid-view');
|
||||
const q = document.getElementById('home-search-input').value.trim();
|
||||
|
||||
if (view === 'home') {
|
||||
homeView.style.display = '';
|
||||
@ -362,25 +467,135 @@
|
||||
'shorts-read': 'Shorts · Recently Read',
|
||||
'novels-read': 'Novels · Recently Read',
|
||||
};
|
||||
document.getElementById('grid-title').textContent = titleMap[view] || '';
|
||||
|
||||
const books =
|
||||
view === 'continue-reading' ? data.continue_reading :
|
||||
view === 'shorts-unread' ? data.shorts_unread :
|
||||
view === 'novels-unread' ? data.novels_unread :
|
||||
view === 'shorts-read' ? data.shorts_read :
|
||||
view === 'novels-read' ? data.novels_read : [];
|
||||
view === 'novels-read' ? data.novels_read :
|
||||
view === 'search' ? searchResults(q) : [];
|
||||
|
||||
document.getElementById('grid-title').textContent = view === 'search' ? `Search: "${q}"` : (titleMap[view] || '');
|
||||
homeView.style.display = 'none';
|
||||
gridView.style.display = '';
|
||||
renderGrid(books);
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
function clearSearch() {
|
||||
const input = document.getElementById('home-search-input');
|
||||
const clear = document.getElementById('home-search-clear');
|
||||
input.value = '';
|
||||
clear.style.display = 'none';
|
||||
if (currentView === 'search') switchView('home');
|
||||
}
|
||||
|
||||
function setupSearch() {
|
||||
const input = document.getElementById('home-search-input');
|
||||
const clear = document.getElementById('home-search-clear');
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim();
|
||||
clear.style.display = q ? '' : 'none';
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
if (q) switchView('search');
|
||||
else if (currentView === 'search') switchView('home');
|
||||
}, 180);
|
||||
});
|
||||
}
|
||||
|
||||
function isImportableFile(file) {
|
||||
const name = String(file?.name || '').toLowerCase();
|
||||
return IMPORT_EXTENSIONS.some(ext => name.endsWith(ext));
|
||||
}
|
||||
|
||||
function openImportPicker() {
|
||||
if (importInProgress) return;
|
||||
const input = document.getElementById('import-file-input');
|
||||
if (input) input.click();
|
||||
}
|
||||
|
||||
function onImportFilesSelected(fileList) {
|
||||
if (!fileList || !fileList.length) return;
|
||||
const files = Array.from(fileList).filter(isImportableFile);
|
||||
if (!files.length) return;
|
||||
uploadImportedFiles(files);
|
||||
const input = document.getElementById('import-file-input');
|
||||
if (input) input.value = '';
|
||||
}
|
||||
|
||||
async function uploadImportedFiles(files) {
|
||||
if (!files.length || importInProgress) return;
|
||||
const zone = document.getElementById('import-dropzone');
|
||||
const title = zone?.querySelector('.import-title');
|
||||
const sub = zone?.querySelector('.import-sub');
|
||||
|
||||
importInProgress = true;
|
||||
zone?.classList.add('uploading');
|
||||
if (title) title.textContent = 'Importing files...';
|
||||
if (sub) sub.textContent = `${files.length} file(s) selected`;
|
||||
|
||||
const form = new FormData();
|
||||
files.forEach(f => form.append('files', f));
|
||||
|
||||
try {
|
||||
const resp = await fetch('/library/import', { method: 'POST', body: form });
|
||||
const payload = await resp.json();
|
||||
if (!resp.ok || payload.error) {
|
||||
alert(payload.error || 'Import failed.');
|
||||
} else {
|
||||
const importedCount = (payload.imported || []).length;
|
||||
const skippedCount = (payload.skipped || []).length;
|
||||
if (title) title.textContent = importedCount ? `Imported ${importedCount} file(s)` : 'No files imported';
|
||||
if (sub) sub.textContent = skippedCount ? `${skippedCount} skipped` : 'Ready for next import';
|
||||
await init();
|
||||
const q = document.getElementById('home-search-input').value.trim();
|
||||
if (q) switchView('search');
|
||||
}
|
||||
} catch {
|
||||
alert('Import failed.');
|
||||
} finally {
|
||||
importInProgress = false;
|
||||
zone?.classList.remove('uploading');
|
||||
setTimeout(() => {
|
||||
if (title) title.textContent = 'Drop EPUB, PDF or CBR/CBZ files here';
|
||||
if (sub) sub.textContent = 'or click to choose files';
|
||||
}, 1200);
|
||||
}
|
||||
}
|
||||
|
||||
function setupImportDropzone() {
|
||||
const zone = document.getElementById('import-dropzone');
|
||||
if (!zone) return;
|
||||
['dragenter', 'dragover'].forEach(evt => {
|
||||
zone.addEventListener(evt, e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!importInProgress) zone.classList.add('dragover');
|
||||
});
|
||||
});
|
||||
['dragleave', 'drop'].forEach(evt => {
|
||||
zone.addEventListener(evt, e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
zone.classList.remove('dragover');
|
||||
});
|
||||
});
|
||||
zone.addEventListener('drop', e => {
|
||||
if (importInProgress) return;
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter(isImportableFile);
|
||||
if (!files.length) return;
|
||||
uploadImportedFiles(files);
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const resp = await fetch('/api/home');
|
||||
data = await resp.json();
|
||||
const [homeResp, libraryResp] = await Promise.all([
|
||||
fetch('/api/home'),
|
||||
fetch('/library/list'),
|
||||
]);
|
||||
data = await homeResp.json();
|
||||
allBooks = (await libraryResp.json()).filter(b => !b.archived);
|
||||
|
||||
const crSection = document.getElementById('cr-section');
|
||||
const shortsSection = document.getElementById('shorts-section');
|
||||
@ -389,6 +604,13 @@
|
||||
const novelsReadSection = document.getElementById('novels-read-section');
|
||||
const homeEmpty = document.getElementById('home-empty');
|
||||
|
||||
crSection.style.display = 'none';
|
||||
shortsSection.style.display = 'none';
|
||||
novelsSection.style.display = 'none';
|
||||
shortsReadSection.style.display = 'none';
|
||||
novelsReadSection.style.display = 'none';
|
||||
homeEmpty.style.display = 'none';
|
||||
|
||||
if (data.continue_reading.length) {
|
||||
crSection.style.display = '';
|
||||
renderRow(document.getElementById('cr-row'), data.continue_reading, true);
|
||||
@ -409,13 +631,14 @@
|
||||
novelsReadSection.style.display = '';
|
||||
renderRow(document.getElementById('novels-read-row'), data.novels_read, false);
|
||||
}
|
||||
|
||||
const hasAny = data.continue_reading.length || data.shorts_unread.length ||
|
||||
data.novels_unread.length || data.shorts_read.length || data.novels_read.length;
|
||||
if (!hasAny) {
|
||||
homeEmpty.style.display = '';
|
||||
}
|
||||
if (!hasAny) homeEmpty.style.display = '';
|
||||
}
|
||||
|
||||
setupSearch();
|
||||
setupImportDropzone();
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@ -32,10 +32,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="import-dropzone" id="import-dropzone" onclick="openImportPicker()">
|
||||
<input type="file" id="import-file-input" accept=".epub,application/epub+zip" multiple style="display:none" onchange="onImportFilesSelected(this.files)"/>
|
||||
<div class="import-title">Drop EPUB files here</div>
|
||||
<input type="file" id="import-file-input" accept=".epub,.pdf,.cbr,.cbz,application/epub+zip,application/pdf" multiple style="display:none" onchange="onImportFilesSelected(this.files)"/>
|
||||
<div class="import-title">Drop EPUB, PDF or CBR/CBZ files here</div>
|
||||
<div class="import-sub">or click to choose files</div>
|
||||
</div>
|
||||
<div id="new-controls" class="new-controls" style="display:none"></div>
|
||||
<div id="grid-container">
|
||||
<div class="empty">Loading…</div>
|
||||
</div>
|
||||
|
||||
@ -1,420 +0,0 @@
|
||||
# Novela 2.0 - Blauwdruk
|
||||
|
||||
> Vervangt repository `story-grabber`. Nieuwe repo: **Novela**.
|
||||
> Stack: FastAPI · Jinja2 · plain JS · PostgreSQL 16 · Docker / Portainer
|
||||
|
||||
---
|
||||
|
||||
## 1. Doelstelling
|
||||
|
||||
Novela 2.0 is een volledig zelfgehoste media-bibliotheek en e-reader voor epub, pdf en cbr/cbz.
|
||||
Het vervangt Kavita (library), Calibre (metadata), en Sigil (epub editor) in een web-applicatie.
|
||||
|
||||
Kernprincipe: **de database is de snelle index, het bestand is de bron van waarheid.**
|
||||
Elke schrijfactie raakt altijd beide: eerst het bestand, dan de database. Lezen gaat altijd via de database.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wat behouden blijft uit v1
|
||||
|
||||
| Module | Bestand | Toelichting |
|
||||
|---|---|---|
|
||||
| EPUB bouw | `epub.py` | `make_epub`, `make_chapter_xhtml`, `add_cover_to_epub` |
|
||||
| EPUB lezen/schrijven | `epub.py` | `read_epub_file`, `write_epub_file` |
|
||||
| XHTML conversie | `xhtml.py` | `element_to_xhtml`, `is_break_element`, `configure_break_patterns` |
|
||||
| Scrapers | `scrapers/` | base, awesomedude, gayauthors, plugin-patroon blijft |
|
||||
| SSE job streaming | `main.py` | `JOBS` dict + `/events/{job_id}` `StreamingResponse` |
|
||||
| Migrations patroon | `migrations.py` | idempotente `CREATE IF NOT EXISTS`, `run_migrations()` bij startup |
|
||||
| Cover cache | DB tabel | `library_cover_cache`, WebP thumbnails 300x450 |
|
||||
| Reading progress | DB tabel | CFI voor epub, paginanummer voor pdf/cbr |
|
||||
| Reading sessions | DB tabel | leesgeschiedenis per boek |
|
||||
| Break patterns | DB tabel | regex + css_class patronen voor scene-breaks |
|
||||
|
||||
---
|
||||
|
||||
## 3. Projectstructuur
|
||||
|
||||
```text
|
||||
novela/
|
||||
├── containers/
|
||||
│ └── novela/
|
||||
│ ├── main.py
|
||||
│ ├── migrations.py
|
||||
│ ├── db.py
|
||||
│ ├── epub.py
|
||||
│ ├── xhtml.py
|
||||
│ ├── pdf.py
|
||||
│ ├── cbr.py
|
||||
│ ├── routers/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── library.py
|
||||
│ │ ├── reader.py
|
||||
│ │ ├── editor.py
|
||||
│ │ ├── grabber.py
|
||||
│ │ ├── backup.py
|
||||
│ │ └── settings.py
|
||||
│ ├── scrapers/
|
||||
│ ├── static/
|
||||
│ ├── templates/
|
||||
│ ├── requirements.txt
|
||||
│ └── Dockerfile
|
||||
├── stack/
|
||||
│ ├── stack.yml
|
||||
│ └── novela.env
|
||||
└── docs/
|
||||
├── BLUEPRINT.md
|
||||
└── TECHNICAL.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Bibliotheek op schijf
|
||||
|
||||
`output/` wordt `library/`.
|
||||
|
||||
```text
|
||||
library/
|
||||
├── epub/
|
||||
│ └── {Publisher}/
|
||||
│ └── {Author}/
|
||||
│ ├── Stories/
|
||||
│ │ └── {Titel}.epub
|
||||
│ └── Series/
|
||||
│ └── {Serienaam}/
|
||||
│ └── {001 - Titel}.epub
|
||||
├── pdf/
|
||||
│ └── {Author}/
|
||||
│ └── {Titel}.pdf
|
||||
├── comics/
|
||||
│ └── {Author of Serienaam}/
|
||||
│ └── {001 - Titel}.cbr
|
||||
└── covers/
|
||||
```
|
||||
|
||||
Naamgeving-regels:
|
||||
- Ongeldige tekens weg: `< > : " / \\ | ? *` en control chars
|
||||
- Max 80 tekens per map-segment, 140 voor bestandsnaam
|
||||
- Bij conflict: `Titel (2).epub`, `Titel (3).epub`, enz.
|
||||
|
||||
Hernoemen na metadata-bewerking:
|
||||
- Bestand verplaatsen op schijf
|
||||
- DB-verwijzingen updaten: `library`, `book_tags`, `reading_progress`, `reading_sessions`, `library_cover_cache`
|
||||
- Lege mappen opruimen
|
||||
|
||||
---
|
||||
|
||||
## 5. Database schema
|
||||
|
||||
### 5.1 `library`
|
||||
|
||||
```sql
|
||||
CREATE TABLE library (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(600) UNIQUE NOT NULL,
|
||||
media_type VARCHAR(10) NOT NULL DEFAULT 'epub',
|
||||
title VARCHAR(500),
|
||||
author VARCHAR(255),
|
||||
publisher VARCHAR(255),
|
||||
series VARCHAR(500),
|
||||
series_index INTEGER DEFAULT 0,
|
||||
publication_status VARCHAR(100),
|
||||
has_cover BOOLEAN DEFAULT FALSE,
|
||||
description TEXT DEFAULT '',
|
||||
source_url VARCHAR(1000),
|
||||
publish_date DATE,
|
||||
archived BOOLEAN DEFAULT FALSE,
|
||||
want_to_read BOOLEAN DEFAULT FALSE,
|
||||
needs_review BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### 5.2 `book_tags`
|
||||
|
||||
```sql
|
||||
CREATE TABLE book_tags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
|
||||
tag VARCHAR(255) NOT NULL,
|
||||
tag_type VARCHAR(20) NOT NULL,
|
||||
UNIQUE (filename, tag, tag_type)
|
||||
);
|
||||
CREATE INDEX idx_book_tags_filename ON book_tags (filename);
|
||||
```
|
||||
|
||||
`tag_type`:
|
||||
- `genre`
|
||||
- `subgenre`
|
||||
- `tag`
|
||||
- `subject`
|
||||
|
||||
### 5.3 `reading_progress`
|
||||
|
||||
```sql
|
||||
CREATE TABLE reading_progress (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(600) UNIQUE NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
|
||||
cfi TEXT,
|
||||
page INTEGER,
|
||||
progress INTEGER DEFAULT 0,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### 5.4 `reading_sessions`
|
||||
|
||||
```sql
|
||||
CREATE TABLE reading_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
|
||||
read_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_reading_sessions_filename ON reading_sessions (filename);
|
||||
```
|
||||
|
||||
### 5.5 `library_cover_cache`
|
||||
|
||||
```sql
|
||||
CREATE TABLE library_cover_cache (
|
||||
filename VARCHAR(600) PRIMARY KEY REFERENCES library(filename) ON DELETE CASCADE,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
thumb_webp BYTEA NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### 5.6 `credentials`
|
||||
|
||||
```sql
|
||||
CREATE TABLE credentials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
site VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### 5.7 `break_patterns`
|
||||
|
||||
```sql
|
||||
CREATE TABLE break_patterns (
|
||||
id SERIAL PRIMARY KEY,
|
||||
pattern_type VARCHAR(20) NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE (pattern_type, pattern)
|
||||
);
|
||||
```
|
||||
|
||||
### 5.8 `backup_log`
|
||||
|
||||
```sql
|
||||
CREATE TABLE backup_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
files_count INTEGER,
|
||||
size_bytes BIGINT,
|
||||
error_msg TEXT,
|
||||
started_at TIMESTAMP DEFAULT NOW(),
|
||||
finished_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Schrijfprincipe: bestand en database synchroon
|
||||
|
||||
Volgorde per bewerking:
|
||||
1. Bewerk bestand op schijf
|
||||
2. Update database
|
||||
3. Retourneer succes
|
||||
|
||||
Nooit alleen DB updaten zonder bestand.
|
||||
|
||||
---
|
||||
|
||||
## 7. Coverstrategie
|
||||
|
||||
Opslaan:
|
||||
- EPUB cover in bestand (`OEBPS/Images/cover.{ext}`)
|
||||
- Thumbnail als `300x450` WebP in `library_cover_cache`
|
||||
|
||||
Ontbrekende cover:
|
||||
- Als geen cover: voeg tag `Cover Missing` toe
|
||||
- UI upload schrijft cover in EPUB en cache
|
||||
|
||||
Opvragen:
|
||||
- Primair: `/library/cover-cached/{filename}`
|
||||
- Fallback: `/library/cover/{filename}`
|
||||
|
||||
PDF en CBR:
|
||||
- PDF: eerste pagina als thumbnail
|
||||
- CBR/CBZ: eerste afbeelding als thumbnail
|
||||
|
||||
---
|
||||
|
||||
## 8. Verwijder-flow
|
||||
|
||||
`DELETE /library/file/{filename}`:
|
||||
1. Verwijder bestand
|
||||
2. Prune lege mappen
|
||||
3. Delete uit `library` (cascade verwijdert gerelateerde tabellen)
|
||||
|
||||
---
|
||||
|
||||
## 9. Router-overzicht
|
||||
|
||||
### 9.1 `routers/library.py`
|
||||
- `GET /library`
|
||||
- `GET /api/library`
|
||||
- `POST /library/rescan`
|
||||
- `POST /library/import`
|
||||
- `DELETE /library/file/{filename}`
|
||||
- `GET /library/cover/{filename}`
|
||||
- `GET /library/cover-cached/{filename}`
|
||||
- `POST /library/cover/{filename}`
|
||||
- `POST /library/want-to-read/{filename}`
|
||||
- `POST /library/archive/{filename}`
|
||||
- `GET /home`
|
||||
- `GET /api/home`
|
||||
- `GET /stats`
|
||||
- `GET /api/stats`
|
||||
|
||||
### 9.2 `routers/reader.py`
|
||||
- `GET /library/read/{filename}`
|
||||
- `GET /library/book/{filename}`
|
||||
- `PATCH /library/book/{filename}`
|
||||
- `GET /library/epub/{filename}`
|
||||
- `GET /library/chapters/{filename}`
|
||||
- `GET /library/chapter/{index}/{filename}`
|
||||
- `GET /library/chapter-img/{path}`
|
||||
- `GET /library/pdf/{filename}`
|
||||
- `GET /library/cbr/{filename}/{page}`
|
||||
- `GET /library/progress/{filename}`
|
||||
- `POST /library/progress/{filename}`
|
||||
- `DELETE /library/progress/{filename}`
|
||||
- `POST /library/mark-read/{filename}`
|
||||
- `GET /api/genres`
|
||||
|
||||
### 9.3 `routers/editor.py`
|
||||
- `GET /library/editor/{filename}`
|
||||
- `GET /api/edit/chapter/{index}/{filename}`
|
||||
- `POST /api/edit/chapter/{index}/{filename}`
|
||||
- `POST /api/edit/chapter/add/{filename}`
|
||||
- `DELETE /api/edit/chapter/{index}/{filename}`
|
||||
|
||||
### 9.4 `routers/grabber.py`
|
||||
- `GET /grabber`
|
||||
- `POST /preload`
|
||||
- `POST /convert`
|
||||
- `GET /events/{job_id}`
|
||||
- `GET /debug`
|
||||
- `POST /debug/run`
|
||||
- `GET /credentials`
|
||||
- `POST /credentials`
|
||||
- `DELETE /credentials/{site}`
|
||||
|
||||
### 9.5 `routers/backup.py`
|
||||
- `GET /backup`
|
||||
- `GET /api/backup/status`
|
||||
- `POST /api/backup/run`
|
||||
- `GET /api/backup/history`
|
||||
|
||||
### 9.6 `routers/settings.py`
|
||||
- `GET /settings`
|
||||
- `GET /api/break-patterns`
|
||||
- `POST /api/break-patterns`
|
||||
- `PATCH /api/break-patterns/{id}`
|
||||
- `DELETE /api/break-patterns/{id}`
|
||||
- `DELETE /api/reading-history`
|
||||
|
||||
---
|
||||
|
||||
## 10. Nieuwe modules
|
||||
|
||||
### 10.1 `db.py`
|
||||
Gedeelde psycopg2 connection pool (`init_pool`, `get_conn`, `release_conn`).
|
||||
|
||||
### 10.2 `pdf.py`
|
||||
PyMuPDF rendering (`pdf_render_page`), page count en cover thumb.
|
||||
|
||||
### 10.3 `cbr.py`
|
||||
RAR/ZIP paginalijst, page extract en cover thumb.
|
||||
|
||||
---
|
||||
|
||||
## 11. Cover-flow per mediatype
|
||||
|
||||
| Actie | EPUB | PDF | CBR/CBZ |
|
||||
|---|---|---|---|
|
||||
| Cover import | Uit OPF/Images | Eerste pagina render | Eerste image uit archief |
|
||||
| Thumbnail | Pillow -> WebP | PyMuPDF + Pillow -> WebP | Pillow -> WebP |
|
||||
| Opslag | EPUB + cache | cache | cache |
|
||||
| Cover vervangen | Ja | Nee | Nee |
|
||||
| Geen cover | `Cover Missing` tag | `Cover Missing` tag | `Cover Missing` tag |
|
||||
|
||||
---
|
||||
|
||||
## 12. Database-opzet
|
||||
|
||||
- Start met schone v2 database
|
||||
- Geen migratiepad vanuit v1 data
|
||||
- `run_migrations()` op startup
|
||||
- `CREATE TABLE IF NOT EXISTS` overal idempotent
|
||||
|
||||
---
|
||||
|
||||
## 13. Docker stack
|
||||
|
||||
Zie [`stack/stack.yml`](../stack/stack.yml).
|
||||
|
||||
Belangrijk:
|
||||
- App container expose `8099 -> 8000`
|
||||
- PostgreSQL 16
|
||||
- Adminer op `8098`
|
||||
- `NOVELA_MASTER_KEY` in `stack/novela.env` en doorgifte in `stack/stack.yml` voor encrypted credentials
|
||||
|
||||
---
|
||||
|
||||
## 14. Requirements
|
||||
|
||||
Zie [`containers/novela/requirements.txt`](../containers/novela/requirements.txt).
|
||||
|
||||
---
|
||||
|
||||
## 15. Bestanden klaarzetten
|
||||
|
||||
Bron: `/docker/develop/story-grabber/containers/story-grabber`.
|
||||
Doel: `/docker/develop/novela/containers/novela`.
|
||||
|
||||
Overnemen:
|
||||
- `epub.py`
|
||||
- `xhtml.py`
|
||||
- `scrapers/*`
|
||||
- `static/*`
|
||||
- `templates/*`
|
||||
|
||||
Nieuw schrijven:
|
||||
- `main.py`, `db.py`, `pdf.py`, `cbr.py`, `migrations.py`
|
||||
- `routers/*`
|
||||
|
||||
---
|
||||
|
||||
## 16. Bouw-volgorde
|
||||
|
||||
1. `db.py`
|
||||
2. `migrations.py`
|
||||
3. `main.py`
|
||||
4. `routers/library.py`
|
||||
5. `routers/reader.py`
|
||||
6. `routers/editor.py`
|
||||
7. `routers/grabber.py`
|
||||
8. `routers/settings.py`
|
||||
9. `pdf.py` + reader uitbreiding
|
||||
10. `cbr.py` + reader uitbreiding
|
||||
11. `routers/backup.py`
|
||||
12. `routers/library.py` uitbreiden voor pdf/cbr import
|
||||
@ -1,100 +1,168 @@
|
||||
# Novela 2.0 - Technical Plan
|
||||
# Novela 2.0 - Technical Status (Develop)
|
||||
|
||||
## Scope
|
||||
Dit document beschrijft de technische uitvoering van de blauwdruk in implementeerbare stappen.
|
||||
Dit document beschrijft de actuele technische status van de `develop` codebase.
|
||||
Dit document is de primaire technische documentatie voor de huidige codebase.
|
||||
|
||||
## Architectural Rules
|
||||
- Bestand is source of truth.
|
||||
- Database is snelle index.
|
||||
- Schrijfacties: eerst bestand, dan DB.
|
||||
- Lezen: primair uit DB, met scan/rescan voor recovery.
|
||||
|
||||
## Data Integrity Rules
|
||||
- Alle child-tabellen refereren `library(filename)` met `ON DELETE CASCADE`.
|
||||
- Verwijderen van een boek is een enkel `DELETE FROM library` na file-delete.
|
||||
- Rename-flow moet `filename` synchroon aanpassen in:
|
||||
- `library`
|
||||
- `book_tags`
|
||||
- `reading_progress`
|
||||
- `reading_sessions`
|
||||
- `library_cover_cache`
|
||||
|
||||
## Runtime Lifecycle
|
||||
- Startup:
|
||||
## Architecture
|
||||
- Stack: FastAPI, Jinja2 templates, plain JS, PostgreSQL 16, Docker.
|
||||
- Startup lifecycle (`main.py`):
|
||||
1. `init_pool()`
|
||||
2. `run_migrations()`
|
||||
3. routers mounten
|
||||
- Shutdown:
|
||||
1. `close_pool()`
|
||||
3. `start_backup_scheduler()`
|
||||
4. routers mounten
|
||||
- Shutdown lifecycle:
|
||||
1. `stop_backup_scheduler()`
|
||||
2. `close_pool()`
|
||||
- Source-of-truth regel: bestand op schijf leidend, database als index/cache.
|
||||
|
||||
## Module Responsibilities
|
||||
- `db.py`: pool ownership + connection helpers.
|
||||
- `migrations.py`: schema + seeds.
|
||||
- `routers/library.py`: import/scan/delete/cover/home/stats.
|
||||
- `routers/reader.py`: lezen + progress + metadata patch + epub editor endpoints.
|
||||
- `routers/editor.py`: uiteindelijke dedicated editor routes (kan initieel delegaten).
|
||||
- `routers/grabber.py`: scraper orchestration + credentials + SSE.
|
||||
- `routers/backup.py`: Dropbox sync + pg dump + logging.
|
||||
- `routers/settings.py`: break patterns + cleaning endpoints.
|
||||
## Router Status
|
||||
|
||||
## Endpoint Contract Notes
|
||||
- Alle file routes gebruiken veilige path-resolutie tegen traversal.
|
||||
- Cover endpoint gedrag:
|
||||
- cached eerst
|
||||
- fallback raw extract
|
||||
- anders 404
|
||||
- Progress payload:
|
||||
- EPUB: `{ cfi, progress }`
|
||||
- PDF/CBR: `{ page, progress }`
|
||||
### `routers/library.py`
|
||||
- `GET /library`
|
||||
- `GET /api/library`
|
||||
- `POST /library/rescan`
|
||||
- `POST /library/import` (EPUB/PDF/CBR/CBZ)
|
||||
- `DELETE /library/file/{filename}`
|
||||
- `GET /library/cover/{filename}`
|
||||
- `GET /library/cover-cached/{filename}`
|
||||
- `POST /library/cover/{filename}` (EPUB)
|
||||
- `POST /library/want-to-read/{filename}`
|
||||
- `POST /library/archive/{filename}`
|
||||
- `POST /library/new/mark-reviewed` (bulk `needs_review=false`)
|
||||
- `GET /home`
|
||||
- `GET /api/home`
|
||||
- `GET /stats`
|
||||
- `GET /api/stats`
|
||||
- `GET /library/list` (compat)
|
||||
|
||||
## Backup Plan
|
||||
- `POST /api/backup/run`:
|
||||
- insert `running` in `backup_log`
|
||||
- sync files naar Dropbox (incremental op mtime+size)
|
||||
- draai `pg_dump` en upload `.sql`
|
||||
- update `backup_log` naar `success`/`error`
|
||||
- OAuth token opslag via `credentials` (`site='dropbox'`) en encrypted-at-rest (Fernet) in de database.
|
||||
- Beheer via webinterface op `/credentials-manager` (site: `dropbox`, token in password veld).
|
||||
- Legacy plaintext credentials worden automatisch gemigreerd naar encrypted bij uitlezen.
|
||||
`GET /api/library` draait standaard in fast-path (DB-only, geen full disk rescan).
|
||||
Voor geforceerde sync: `GET /api/library?rescan=true` of `POST /library/rescan`.
|
||||
`include_file_info=true` is optioneel voor bestandsgrootte/mtime verrijking.
|
||||
|
||||
## Migration Plan from Current State
|
||||
1. Behoud v1 stabiele modules (`epub.py`, `xhtml.py`, scrapers, templates/static).
|
||||
2. Introduceer nieuwe routers zonder bestaande frontend te breken (compat routes waar nodig).
|
||||
3. Schakel library root om naar `library/`.
|
||||
4. Activeer PDF/CBR scan en reader paden.
|
||||
5. Split editor-routes uit reader naar dedicated `editor.py`.
|
||||
6. Volledige scrape->epub flow migreren naar `grabber.py`.
|
||||
7. Backup volledig afronden (Dropbox + pg_dump).
|
||||
`/api/home` levert:
|
||||
- `continue_reading`
|
||||
- `shorts_unread`
|
||||
- `novels_unread`
|
||||
- `shorts_read`
|
||||
- `novels_read`
|
||||
|
||||
## Test Matrix
|
||||
- Import:
|
||||
- EPUB met/zonder cover
|
||||
- PDF 1+ pagina
|
||||
- CBR/CBZ met images
|
||||
- Reader:
|
||||
- EPUB CFI save/load
|
||||
- PDF page render + page progress
|
||||
- CBR page render + page progress
|
||||
- Metadata edit:
|
||||
- rename path
|
||||
- db references geupdate
|
||||
- old row cleanup
|
||||
- Delete:
|
||||
- file weg
|
||||
- lege dirs gepruned
|
||||
- cascade records weg
|
||||
- Break patterns:
|
||||
- create/update/delete/enable
|
||||
- Grabber:
|
||||
- preload/debug
|
||||
- convert job events
|
||||
- Backup:
|
||||
- status/history
|
||||
- success/error logging
|
||||
`/api/stats` levert naast totals ook chart- en history-data voor `stats.html`:
|
||||
- `reads_by_month`, `reads_by_dow`, `reads_by_hour`
|
||||
- `genre_counts`, `publisher_counts`, `fav_genre`, `fav_publisher`
|
||||
- `top_books`, `history`
|
||||
|
||||
## Deployment Notes
|
||||
- Docker image bouwt vanuit `containers/novela`.
|
||||
- Stack uit `stack/stack.yml` met env uit `stack/novela.env`.
|
||||
- `NOVELA_MASTER_KEY` is verplicht voor encrypt/decrypt van credentials in de database en moet stabiel blijven na initiele ingebruikname.
|
||||
- Postgres volume persistent.
|
||||
- Library mount persistent.
|
||||
Home-secties filteren series uit met:
|
||||
- `COALESCE(series, '') = ''`
|
||||
- `filename NOT LIKE '%/Series/%'`
|
||||
|
||||
Read-secties op Home zijn gesorteerd op oudste eerst:
|
||||
- `shorts_read`: `ORDER BY MAX(read_at) ASC`
|
||||
- `novels_read`: `ORDER BY MAX(read_at) ASC`
|
||||
|
||||
### `routers/reader.py`
|
||||
- Epub serving/chapters/images
|
||||
- Reader pagina + book detail
|
||||
- Metadata patch (`PATCH /library/book/{filename}`)
|
||||
- Progress read/write/delete
|
||||
- Mark-as-read
|
||||
- PDF render endpoint
|
||||
- CBR/CBZ page endpoint
|
||||
- Genres endpoint
|
||||
|
||||
### `routers/editor.py`
|
||||
- Editor pagina
|
||||
- Chapter get/save
|
||||
- Chapter add
|
||||
- Chapter delete
|
||||
|
||||
### `routers/grabber.py`
|
||||
- Grabber pagina + convert/debug flows
|
||||
- SSE events
|
||||
- Credentials beheer voor scraper-sites
|
||||
- Credentials manager UI (`/credentials-manager`)
|
||||
|
||||
### `routers/backup.py`
|
||||
- `GET /backup`
|
||||
- `GET/POST/DELETE /api/backup/credentials`
|
||||
- `GET /api/backup/health`
|
||||
- `GET /api/backup/status`
|
||||
- `GET /api/backup/history`
|
||||
- `POST /api/backup/run`
|
||||
|
||||
## Backup & Security
|
||||
- Dropbox token encrypted-at-rest in `credentials` (`site='dropbox'`).
|
||||
- Dropbox backup root encrypted opgeslagen in `credentials` (`site='dropbox_backup_root'`).
|
||||
- Retentie (`snapshots to keep`) encrypted opgeslagen in `credentials` (`site='dropbox_backup_retention'`).
|
||||
- Backup schedule (`enabled` + `interval_hours`) encrypted opgeslagen in `credentials` (`site='dropbox_backup_schedule'`).
|
||||
- Encryptie via `NOVELA_MASTER_KEY` (Fernet).
|
||||
|
||||
Implementatie:
|
||||
- Versie-gebaseerde backups met deduplicatie:
|
||||
- bestandsobjecten in Dropbox: `library_objects/{sha256_prefix}/{sha256}`
|
||||
- snapshots in Dropbox: `library_snapshots/snapshot-YYYYMMDD-HHMMSS.json`
|
||||
- Elke run maakt een nieuwe snapshot (versie) en uploadt alleen ontbrekende objecten.
|
||||
- Retentie verwijdert oude snapshots boven de ingestelde limiet.
|
||||
- Orphan object-prune verwijdert objecten die niet meer door retained snapshots worden gerefereerd.
|
||||
- Lokale manifestcache (`config/backup_manifest.json`) versnelt changeddetectie.
|
||||
- Database backup via `pg_dump` naar Dropbox `postgres/`.
|
||||
- `POST /api/backup/run` start altijd als background task en geeft direct status terug.
|
||||
- Scheduler draait in achtergrond (`start_backup_scheduler`) en triggert op interval als backup aanstaat.
|
||||
- Concurrentierestrictie: slechts 1 backup tegelijk.
|
||||
- Bij container restart/crash worden stale `running` logs automatisch gemarkeerd als interrupted/error.
|
||||
|
||||
## Environment
|
||||
`stack/novela.env` bevat nu minimaal:
|
||||
- `POSTGRES_DB`
|
||||
- `POSTGRES_USER`
|
||||
- `POSTGRES_PASSWORD`
|
||||
- `NOVELA_MASTER_KEY`
|
||||
- `CONFIG_DIR`
|
||||
|
||||
Dropbox settings verlopen via webinterface op `/backup`.
|
||||
|
||||
## UI Notes
|
||||
- Library import accepteert: EPUB/PDF/CBR/CBZ.
|
||||
- Home heeft dezelfde importmogelijkheden.
|
||||
- Home heeft zoekfunctionaliteit.
|
||||
- Home header/dropzone uitlijning gelijkgetrokken met Library (zoek rechtsboven, dropzone eronder).
|
||||
- `New` view ondersteunt `Grid` en `List` mode.
|
||||
- Bulk selectie + `Remove from New` werkt alleen in `List` mode.
|
||||
- `List` mode heeft kolomfilter (aan/uit) met kolommen:
|
||||
- Publisher
|
||||
- Author
|
||||
- Series
|
||||
- Volume
|
||||
- Title
|
||||
- Has cover
|
||||
- Updated
|
||||
- Genres
|
||||
- Sub-genres
|
||||
- Tags
|
||||
- Status
|
||||
- `List` mode ondersteunt multi-select met `Shift+klik` range-select op checkboxes.
|
||||
- `Grid` mode toont geen selectie-checkboxes of bulkacties.
|
||||
- Backup pagina ondersteunt:
|
||||
- handmatige run en dry-run
|
||||
- instellingen voor Dropbox root
|
||||
- retentie-aantal snapshots
|
||||
- geplande backup (aan/uit + interval in uren)
|
||||
- status + history overzicht
|
||||
|
||||
## Known Conventions
|
||||
- Verwijderen boek: bestand verwijderen, lege mappen prunen, daarna `DELETE FROM library` (cascade op child-tabellen).
|
||||
- Cover strategie:
|
||||
- EPUB: cover uit bestand + cache
|
||||
- PDF/CBR: thumbnail via cover cache
|
||||
|
||||
|
||||
## Performance Notes
|
||||
- Library-load geoptimaliseerd voor grote datasets:
|
||||
- `list_library_json()` gebruikt pre-aggregatie voor `reading_sessions`.
|
||||
- `has_cached_cover` komt direct uit SQL join i.p.v. losse volledige cache-fetch.
|
||||
- Nieuwe migrations-indexen:
|
||||
- `idx_library_sort_coalesce`
|
||||
- `idx_library_needs_review`
|
||||
- `idx_library_archived`
|
||||
- `idx_reading_sessions_filename_readat`
|
||||
- `idx_book_tags_filename_tag`
|
||||
|
||||
47
docs/changelog-develop.md
Normal file
47
docs/changelog-develop.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Changelog Develop
|
||||
|
||||
Dit bestand houdt wijzigingen op de `develop` lijn bij.
|
||||
`changelog.md` wordt later gebruikt voor release-samenvattingen.
|
||||
|
||||
## 2026-03-22
|
||||
- Blueprint en technische documentatie toegevoegd in `docs/`.
|
||||
- Router-splitsing en bootstrapstructuur afgerond (`main.py`, routers, migrations, db pool).
|
||||
- Media support uitgebreid naar EPUB/PDF/CBR/CBZ in import- en scanflow.
|
||||
- Home UI uitgebreid met:
|
||||
- import-dropzone voor EPUB/PDF/CBR/CBZ
|
||||
- zoekfunctie
|
||||
- uitlijning gelijk aan Library (zoek rechtsboven, dropzone eronder)
|
||||
- Library UI importteksten en drag/drop filtering bijgewerkt voor multi-format.
|
||||
- Library `New` view uitgebreid:
|
||||
- `Grid`/`List` toggle
|
||||
- kolomfilter in `List`
|
||||
- multi-select + bulk `Remove from New`
|
||||
- selectie alleen in `List` mode
|
||||
- `Shift+klik` range-select op checkboxes
|
||||
- Nieuwe route toegevoegd: `POST /library/new/mark-reviewed` (bulk `needs_review=false`).
|
||||
- Library performance verbeterd:
|
||||
- `/api/library` fast-path (geen full rescan per page-load)
|
||||
- optionele `rescan=true`/`include_file_info=true`
|
||||
- SQL-optimalisatie in `list_library_json()`
|
||||
- extra DB-indexen voor schaal
|
||||
- `/api/home` hersteld naar volledige dataset-output:
|
||||
- `continue_reading`
|
||||
- `shorts_unread`
|
||||
- `novels_unread`
|
||||
- `shorts_read`
|
||||
- `novels_read`
|
||||
- Home-sectiefilters expliciet zonder serieboeken gezet.
|
||||
- Home read-volgorde gecorrigeerd: in `shorts_read` en `novels_read` staat de oudste bovenaan (`ORDER BY MAX(read_at) ASC`).
|
||||
- Statistics pagina hersteld: `/api/stats` levert weer volledige payload voor charts, favorieten, topboeken en reading history.
|
||||
- Backup verbeterd:
|
||||
- Dropbox token encrypted opgeslagen in DB
|
||||
- Dropbox backup root instelbaar via webinterface en encrypted in DB
|
||||
- versie-gebaseerde snapshots + object-store deduplicatie in Dropbox (`library_snapshots` / `library_objects`)
|
||||
- instelbare snapshot-retentie (`snapshots to keep`) via backup settings
|
||||
- object prune op basis van retained snapshots
|
||||
- geplande backup (enable + interval in uren)
|
||||
- backup runs als background process zodat navigeren op site door kan lopen
|
||||
- herstel op stale running state na restart/crash (oude running logs markeren als interrupted/error)
|
||||
- dry-run ondersteuning op nieuwe flow
|
||||
- Docker image aangepast met `postgresql-client` voor `pg_dump`.
|
||||
- Meerdere test builds uitgevoerd en gepusht naar `gitea.oskamp.info/ivooskamp/novela:dev`.
|
||||
@ -6,8 +6,5 @@ POSTGRES_PASSWORD=change-me
|
||||
# Keep this stable after first use; changing it breaks decrypt of existing credentials.
|
||||
NOVELA_MASTER_KEY=change-me-long-random-secret
|
||||
|
||||
# Dropbox root-map voor backup uploads (default: /novela)
|
||||
DROPBOX_BACKUP_ROOT=/novela
|
||||
|
||||
# Map voor backup manifest/config binnen container (default: config)
|
||||
CONFIG_DIR=config
|
||||
|
||||
Loading…
Reference in New Issue
Block a user