From 84d95dc88692407559e5a5e6662d3767b554bc99 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Mon, 1 Jun 2026 22:17:51 +0200 Subject: [PATCH 1/2] Dev build 2026-06-01 22:17 --- containers/novela/routers/backup.py | 364 +++++++++++++++++------- containers/novela/templates/backup.html | 97 +++++-- containers/novela/version.py | 2 +- docs/changelog-develop.md | 11 + 4 files changed, 360 insertions(+), 114 deletions(-) diff --git a/containers/novela/routers/backup.py b/containers/novela/routers/backup.py index 41ad799..361e10e 100644 --- a/containers/novela/routers/backup.py +++ b/containers/novela/routers/backup.py @@ -5,6 +5,7 @@ import json import os import shutil import subprocess +import time from datetime import date, datetime, timezone from pathlib import Path from tempfile import NamedTemporaryFile @@ -45,6 +46,27 @@ BACKUP_TASKS: dict[int, asyncio.Task] = {} BACKUP_PROGRESS: dict[int, dict] = {} # log_id → {done, total, phase} SCHEDULER_TASK: asyncio.Task | None = None +# Full-database restore runs in the background (one at a time) and reports +# byte-level progress here, polled via GET /api/backup/restore/progress. +RESTORE_PROGRESS: dict = { + "active": False, + "phase": "idle", # downloading | safety_dump | resetting | loading | rolling_back | done | error + "done": 0, + "total": 0, + "label": "", + "name": "", + "size": 0, + "ok": None, + "error": None, +} +RESTORE_TASK: "asyncio.Task | None" = None + + +def _restore_prog(phase: str, done: int = 0, total: int = 0, label: str = "") -> None: + RESTORE_PROGRESS.update( + {"phase": phase, "done": int(done), "total": int(total), "label": label} + ) + def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() @@ -781,18 +803,27 @@ def _real_restore_errors(stderr: str) -> list[str]: return errors -def _apply_pg_dump(dump_bytes: bytes) -> None: - """Reset the public schema and load a dump into it. +def _apply_pg_dump_file( + path: Path, + total: int, + *, + load_phase: str = "loading", + load_label: str = "Loading dump", + reset_label: str = "Resetting schema", +) -> None: + """Reset the public schema and load a dump file into it, reporting progress. - The dump is applied WITHOUT ON_ERROR_STOP so that benign header SETs the - target server doesn't recognize (e.g. a newer pg_dump emitting - `transaction_timeout` against an older server) don't abort the load. - stderr is then inspected: any non-benign `ERROR:` line raises. + The dump is streamed to psql over stdin in chunks; the byte counter tracks + how much has been fed, which (thanks to pipe back-pressure) closely follows + psql's actual progress. The load runs WITHOUT ON_ERROR_STOP so benign header + SETs an older server doesn't recognise (e.g. `transaction_timeout`) don't + abort it; stderr is inspected afterwards and any non-benign `ERROR:` raises. """ env = os.environ.copy() env["PGPASSWORD"] = os.environ.get("POSTGRES_PASSWORD", "") base = _psql_base_args() + _restore_prog("resetting", 0, 0, reset_label) reset = subprocess.run( ["psql", *base, "-v", "ON_ERROR_STOP=1", "-c", "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"], env=env, @@ -802,64 +833,198 @@ def _apply_pg_dump(dump_bytes: bytes) -> None: if reset.returncode != 0: raise RuntimeError(f"schema reset failed: {(reset.stderr or '').strip()[:500] or 'unknown error'}") - with NamedTemporaryFile(suffix=".sql", delete=False) as tmp: - tmp_path = Path(tmp.name) - tmp_path.write_bytes(dump_bytes) + _restore_prog(load_phase, 0, total, load_label) + with NamedTemporaryFile(suffix=".stderr", delete=False) as errtmp: + err_path = Path(errtmp.name) try: - proc = subprocess.run( - ["psql", *base, "-f", str(tmp_path)], - env=env, - capture_output=True, - text=True, - ) - real_errors = _real_restore_errors(proc.stderr) + with err_path.open("wb") as err_fh, path.open("rb") as src: + proc = subprocess.Popen( + ["psql", *base, "-q", "-f", "-"], + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=err_fh, + ) + sent = 0 + try: + while True: + chunk = src.read(1024 * 1024) + if not chunk: + break + proc.stdin.write(chunk) + sent += len(chunk) + _restore_prog(load_phase, sent, total, load_label) + except BrokenPipeError: + # psql exited early (e.g. fatal error); error is captured in stderr. + pass + finally: + try: + proc.stdin.close() + except Exception: + pass + proc.wait() + + stderr_text = err_path.read_text(encoding="utf-8", errors="replace") + real_errors = _real_restore_errors(stderr_text) if real_errors: raise RuntimeError("psql restore failed: " + " | ".join(real_errors)[:500]) - finally: - tmp_path.unlink(missing_ok=True) - - -def _run_pg_restore(dump_bytes: bytes) -> None: - """Restore a full PostgreSQL dump, with automatic rollback on failure. - - Before the destructive load, a safety dump of the CURRENT database is taken. - If applying the requested dump fails, the database is rolled back to that - safety snapshot so a failed restore never leaves an empty/broken database. - - The safety dump is also written to the local dump store, so every restore - leaves behind a token-free local snapshot of the pre-restore state. Regular - backups stay Dropbox-only (they can run hourly and would otherwise fill the - disk). - """ - try: - safety_bytes, safety_name = _run_pg_dump() - except Exception: - safety_bytes, safety_name = None, "" - - if safety_bytes is not None: - try: - _save_local_dump(f"pre-restore-{safety_name}", safety_bytes) - _enforce_local_dump_retention(_load_dropbox_retention_count()) - except Exception: - # A local-copy failure must not block the restore. - pass - - try: - _apply_pg_dump(dump_bytes) - except Exception as restore_err: - if safety_bytes is not None: - try: - _apply_pg_dump(safety_bytes) - except Exception as rollback_err: - raise RuntimeError( - f"restore failed: {restore_err}; AND rollback failed: {rollback_err}. " - "Database may be in an inconsistent state." - ) + if proc.returncode != 0 and not real_errors: raise RuntimeError( - f"restore failed and was rolled back to the pre-restore state: {restore_err}" + f"psql exited with code {proc.returncode}: {stderr_text.strip()[:300] or 'unknown error'}" ) - raise + finally: + err_path.unlink(missing_ok=True) + + +def _pg_dump_safety_to_local() -> Path: + """Run pg_dump of the CURRENT database into the local store, with progress.""" + db = os.environ.get("POSTGRES_DB", "novela") + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + LOCAL_DUMP_DIR.mkdir(parents=True, exist_ok=True) + dest = LOCAL_DUMP_DIR / f"pre-restore-{db}-{stamp}.sql" + + env = os.environ.copy() + env["PGPASSWORD"] = os.environ.get("POSTGRES_PASSWORD", "") + proc = subprocess.Popen( + _pg_dump_cmd(dest), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + while proc.poll() is None: + try: + _restore_prog("safety_dump", dest.stat().st_size, 0, "Creating safety snapshot") + except OSError: + pass + time.sleep(0.5) + _out, err = proc.communicate() + if proc.returncode != 0: + raise RuntimeError(f"pg_dump failed: {(err or '').strip()[:300] or 'unknown error'}") + return dest + + +def _download_dropbox_to_file(client: dropbox.Dropbox, src: str, dest: Path) -> None: + md, res = client.files_download(src) + total = int(getattr(md, "size", 0) or 0) + done = 0 + _restore_prog("downloading", 0, total, "Downloading dump") + dest.parent.mkdir(parents=True, exist_ok=True) + with dest.open("wb") as f: + for chunk in res.iter_content(chunk_size=1024 * 1024): + if not chunk: + continue + f.write(chunk) + done += len(chunk) + _restore_prog("downloading", done, total, "Downloading dump") + + +def _restore_worker_sync( + *, + source: str, + dump_path: "Path | None" = None, + dropbox_name: str | None = None, + cleanup_path: str | None = None, +) -> None: + """Background full-database restore with safety snapshot and rollback. + + Phases reported via RESTORE_PROGRESS: downloading (Dropbox only) → safety_dump + → resetting → loading → done | error. On a failed load the database is rolled + back to the pre-restore safety snapshot. + """ + safety_path: "Path | None" = None + try: + # 1. Obtain the dump as a local file. + if source == "dropbox": + client = _dbx() + postgres_root = _dropbox_join(_load_dropbox_root(), "postgres") + src = _dropbox_join(postgres_root, dropbox_name or "") + LOCAL_DUMP_DIR.mkdir(parents=True, exist_ok=True) + dump_path = LOCAL_DUMP_DIR / (dropbox_name or "download.sql") + _download_dropbox_to_file(client, src, dump_path) + try: + _enforce_local_dump_retention(_load_dropbox_retention_count()) + except Exception: + pass + + if dump_path is None or not Path(dump_path).is_file(): + raise RuntimeError("Dump file not available for restore") + dump_path = Path(dump_path) + total = dump_path.stat().st_size + + # 2. Safety snapshot of the current database (saved locally, token-free). + try: + safety_path = _pg_dump_safety_to_local() + try: + _enforce_local_dump_retention(_load_dropbox_retention_count()) + except Exception: + pass + except Exception: + safety_path = None + + # 3. + 4. Reset schema and load the dump. + try: + _apply_pg_dump_file(dump_path, total) + except Exception as restore_err: + if safety_path is not None and safety_path.exists(): + try: + _apply_pg_dump_file( + safety_path, + safety_path.stat().st_size, + load_phase="rolling_back", + load_label="Rolling back (loading safety snapshot)", + reset_label="Rolling back (resetting schema)", + ) + except Exception as rollback_err: + RESTORE_PROGRESS.update( + {"ok": False, "error": f"restore failed: {restore_err}; AND rollback failed: {rollback_err}"} + ) + _restore_prog("error", 0, 0, "Failed") + return + RESTORE_PROGRESS.update( + {"ok": False, "error": f"restore failed and was rolled back to the pre-restore state: {restore_err}"} + ) + _restore_prog("error", 0, 0, "Rolled back") + return + RESTORE_PROGRESS.update({"ok": False, "error": str(restore_err)}) + _restore_prog("error", 0, 0, "Failed") + return + + RESTORE_PROGRESS.update({"ok": True, "error": None, "size": total}) + _restore_prog("done", total, total, "Done") + except Exception as e: + RESTORE_PROGRESS.update({"ok": False, "error": str(e)}) + _restore_prog("error", 0, 0, "Failed") + finally: + RESTORE_PROGRESS["active"] = False + if cleanup_path: + try: + Path(cleanup_path).unlink(missing_ok=True) + except Exception: + pass + + +def _start_restore(*, name: str, **kwargs) -> bool: + """Start a background restore if none is running. Returns False if busy.""" + global RESTORE_TASK + if RESTORE_PROGRESS.get("active"): + return False + RESTORE_PROGRESS.update( + { + "active": True, + "phase": "starting", + "done": 0, + "total": 0, + "label": "Starting", + "name": name, + "size": 0, + "ok": None, + "error": None, + } + ) + RESTORE_TASK = asyncio.create_task(asyncio.to_thread(_restore_worker_sync, **kwargs)) + return True def _list_pg_dump_paths(client: dropbox.Dropbox, postgres_root: str) -> list[str]: @@ -875,11 +1040,18 @@ def _save_local_dump(name: str, data: bytes) -> None: (LOCAL_DUMP_DIR / safe).write_bytes(data) +def _local_dump_mtime(p: Path) -> float: + try: + return p.stat().st_mtime + except OSError: + return 0.0 + + def _list_local_dumps() -> list[Path]: if not LOCAL_DUMP_DIR.exists(): return [] dumps = [p for p in LOCAL_DUMP_DIR.glob("*.sql") if p.is_file()] - return sorted(dumps, key=lambda p: p.name, reverse=True) + return sorted(dumps, key=_local_dump_mtime, reverse=True) def _enforce_local_dump_retention(keep_count: int) -> None: @@ -1799,30 +1971,20 @@ async def restore_pg_dump(request: Request): if not shutil.which("psql"): return {"ok": False, "error": "psql is not available in this container"} + if RESTORE_PROGRESS.get("active"): + return {"ok": False, "error": "A restore is already running."} + + # Validate Dropbox access up front so a bad token fails fast (the actual + # download happens in the background worker with progress reporting). try: - client = await asyncio.to_thread(_dbx) + await asyncio.to_thread(_dbx) except Exception as e: return {"ok": False, "error": str(e)} - dropbox_root = _load_dropbox_root() - postgres_root = _dropbox_join(dropbox_root, "postgres") - dump_path = _dropbox_join(postgres_root, name) - - try: - def _download() -> bytes: - _meta, res = client.files_download(dump_path) - return res.content - - data = await asyncio.to_thread(_download) - except Exception as e: - return {"ok": False, "error": f"Failed to download dump: {e}"} - - try: - await asyncio.to_thread(_run_pg_restore, data) - except Exception as e: - return {"ok": False, "error": str(e)} - - return {"ok": True, "restored": name, "size_bytes": len(data)} + started = _start_restore(name=name, source="dropbox", dropbox_name=name) + if not started: + return {"ok": False, "error": "A restore is already running."} + return {"ok": True, "started": True, "name": name} @router.get("/api/backup/local/dumps") @@ -1853,29 +2015,27 @@ async def restore_local_dump(request: Request): return {"ok": False, "error": "Local dump not found"} if not shutil.which("psql"): return {"ok": False, "error": "psql is not available in this container"} + if RESTORE_PROGRESS.get("active"): + return {"ok": False, "error": "A restore is already running."} - try: - data = await asyncio.to_thread(path.read_bytes) - except Exception as e: - return {"ok": False, "error": f"Failed to read local dump: {e}"} - - try: - await asyncio.to_thread(_run_pg_restore, data) - except Exception as e: - return {"ok": False, "error": str(e)} - - return {"ok": True, "restored": path.name, "size_bytes": len(data)} + started = _start_restore(name=path.name, source="local", dump_path=path) + if not started: + return {"ok": False, "error": "A restore is already running."} + return {"ok": True, "started": True, "name": path.name} @router.post("/api/backup/upload-restore") async def upload_restore_dump(file: UploadFile = File(...)): """Restore the database from an uploaded .sql dump. No Dropbox token required. - A local pre-restore safety snapshot is taken automatically (by _run_pg_restore) - before the load; the uploaded file itself is not persisted. + The upload is written to a temp file and restored in the background (with + progress); a local pre-restore safety snapshot is taken first. The temp file + is removed afterwards (the uploaded dump itself is not persisted). """ if not shutil.which("psql"): return {"ok": False, "error": "psql is not available in this container"} + if RESTORE_PROGRESS.get("active"): + return {"ok": False, "error": "A restore is already running."} filename = Path(file.filename or "uploaded.sql").name if not filename.endswith(".sql"): @@ -1888,9 +2048,19 @@ async def upload_restore_dump(file: UploadFile = File(...)): if not data: return {"ok": False, "error": "Uploaded file is empty"} - try: - await asyncio.to_thread(_run_pg_restore, data) - except Exception as e: - return {"ok": False, "error": str(e)} + with NamedTemporaryFile(suffix=".sql", delete=False) as tmp: + tmp_path = Path(tmp.name) + tmp_path.write_bytes(data) - return {"ok": True, "restored": filename, "size_bytes": len(data)} + started = _start_restore( + name=filename, source="upload", dump_path=tmp_path, cleanup_path=str(tmp_path) + ) + if not started: + tmp_path.unlink(missing_ok=True) + return {"ok": False, "error": "A restore is already running."} + return {"ok": True, "started": True, "name": filename} + + +@router.get("/api/backup/restore/progress") +async def restore_progress(): + return dict(RESTORE_PROGRESS) diff --git a/containers/novela/templates/backup.html b/containers/novela/templates/backup.html index dcd4869..c02e3ad 100644 --- a/containers/novela/templates/backup.html +++ b/containers/novela/templates/backup.html @@ -806,6 +806,61 @@ document.getElementById('btn-pg-restore').disabled = !e.target.value; }); + // ── Shared restore progress (background task + polling) ─────────────────── + + const _restorePhaseLabels = { + starting: 'Starting', downloading: 'Downloading dump', + safety_dump: 'Creating safety snapshot', resetting: 'Resetting schema', + loading: 'Loading dump', rolling_back: 'Rolling back', + done: 'Done', error: 'Failed', + }; + function _sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + let _restorePolling = false; + + function _restoreProgressText(p) { + const lbl = _restorePhaseLabels[p.phase] || p.phase || 'Working'; + if (p.total > 0) { + const pct = Math.min(100, Math.floor((p.done / p.total) * 100)); + return `${lbl}… ${pct}% (${fmtBytes(p.done)} / ${fmtBytes(p.total)})`; + } + if (p.done > 0) return `${lbl}… ${fmtBytes(p.done)}`; + return `${lbl}…`; + } + + // Poll restore progress, mirroring status into `out`. Returns final progress. + async function pollRestoreProgress(out, btns) { + _restorePolling = true; + try { + while (true) { + let p; + try { + const r = await fetch('/api/backup/restore/progress'); + p = await r.json(); + } catch (_) { await _sleep(1500); continue; } + if (!p.active) { + if (p.ok) { + out.className = 'status-line ok'; + out.textContent = `Database restored from ${p.name} (${fmtBytes(p.size || p.total)}). Reload the app to see the restored library.`; + } else if (p.error) { + out.className = 'status-line err'; + out.textContent = `Restore failed: ${p.error}`; + } else { + out.className = 'status-line'; + out.textContent = ''; + } + (btns || []).forEach(b => { if (b) b.disabled = false; }); + await loadLocalDumps(); + return p; + } + out.className = 'status-line warn'; + out.textContent = `${_restoreProgressText(p)} — do not navigate away`; + await _sleep(1000); + } + } finally { + _restorePolling = false; + } + } + async function restorePgDump() { const name = document.getElementById('pgdump-select').value; const out = document.getElementById('pgdump-status'); @@ -815,7 +870,7 @@ const btn = document.getElementById('btn-pg-restore'); btn.disabled = true; out.className = 'status-line warn'; - out.textContent = `Restoring database from ${name}… (do not navigate away)`; + out.textContent = 'Starting restore…'; try { const r = await fetch('/api/backup/postgres/restore', { method: 'POST', @@ -823,13 +878,11 @@ body: JSON.stringify({name}), }); const d = await r.json(); - if (!d.ok) throw new Error(d.error || 'failed'); - out.className = 'status-line ok'; - out.textContent = `Database restored from ${d.restored} (${fmtBytes(d.size_bytes)}). Reload the app to see the restored library.`; + if (!d.ok || !d.started) throw new Error(d.error || 'failed to start'); + await pollRestoreProgress(out, [btn]); } catch (e) { out.className = 'status-line err'; out.textContent = `Database restore failed: ${e}`; - } finally { btn.disabled = false; } } @@ -869,7 +922,7 @@ const btn = document.getElementById('btn-local-restore'); btn.disabled = true; out.className = 'status-line warn'; - out.textContent = `Restoring database from ${name}… (do not navigate away)`; + out.textContent = 'Starting restore…'; try { const r = await fetch('/api/backup/local/restore', { method: 'POST', @@ -877,13 +930,11 @@ body: JSON.stringify({name}), }); const d = await r.json(); - if (!d.ok) throw new Error(d.error || 'failed'); - out.className = 'status-line ok'; - out.textContent = `Database restored from ${d.restored} (${fmtBytes(d.size_bytes)}). Reload the app to see the restored library.`; + if (!d.ok || !d.started) throw new Error(d.error || 'failed to start'); + await pollRestoreProgress(out, [btn]); } catch (e) { out.className = 'status-line err'; out.textContent = `Local restore failed: ${e}`; - } finally { btn.disabled = false; } } @@ -901,26 +952,40 @@ const btn = document.getElementById('btn-upload-restore'); btn.disabled = true; out.className = 'status-line warn'; - out.textContent = `Uploading and restoring from ${f.name}… (do not navigate away)`; + out.textContent = `Uploading ${f.name}…`; try { const fd = new FormData(); fd.append('file', f); const r = await fetch('/api/backup/upload-restore', {method: 'POST', body: fd}); const d = await r.json(); - if (!d.ok) throw new Error(d.error || 'failed'); - out.className = 'status-line ok'; - out.textContent = `Database restored from ${d.restored} (${fmtBytes(d.size_bytes)}). Reload the app to see the restored library.`; + if (!d.ok || !d.started) throw new Error(d.error || 'failed to start'); input.value = ''; - await loadLocalDumps(); + await pollRestoreProgress(out, [btn]); } catch (e) { out.className = 'status-line err'; out.textContent = `Upload restore failed: ${e}`; - } finally { btn.disabled = false; } } + // Resume the progress display if a restore is already running (e.g. after a reload). + async function resumeRestoreIfRunning() { + try { + const r = await fetch('/api/backup/restore/progress'); + const p = await r.json(); + if (p.active && !_restorePolling) { + const out = document.getElementById('localdump-status'); + pollRestoreProgress(out, [ + document.getElementById('btn-pg-restore'), + document.getElementById('btn-local-restore'), + document.getElementById('btn-upload-restore'), + ]); + } + } catch (_) { /* ignore */ } + } + refreshAll(); + resumeRestoreIfRunning(); diff --git a/containers/novela/version.py b/containers/novela/version.py index ab44aca..4253908 100644 --- a/containers/novela/version.py +++ b/containers/novela/version.py @@ -10,7 +10,7 @@ from __future__ import annotations from changelog import CHANGELOG -BUILD = 0 +BUILD = 1 def _release_version() -> str: diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md index 514b2c3..ab51018 100644 --- a/docs/changelog-develop.md +++ b/docs/changelog-develop.md @@ -1,5 +1,16 @@ # Develop Changelog +## 2026-06-01 — Full Database Restore: live progress + download-to-local first + +### Added +- Full database restores (Dropbox, local and upload) now run as a **background task with live byte-level progress**, polled by the Backup page, instead of a silent blocking request. This matters for large (e.g. ~1 GB) production databases where the restore previously showed nothing for minutes. + - Phases reported and shown with a percentage / size: **downloading** (Dropbox only) → **safety snapshot** → **resetting schema** → **loading** → done / rolled back / failed. + - Load progress is derived from feeding the dump to `psql` over stdin in chunks; pipe back-pressure makes the byte counter track psql's real progress closely. +- **Dropbox restore now downloads the dump to the local store first**, then restores from that file — so the restore no longer depends on the network connection mid-load, and a reusable local copy is kept for token-free restores. (Previously the Dropbox dump was streamed straight into memory and not persisted.) + - `routers/backup.py`: `RESTORE_PROGRESS` state + `_restore_prog`; `GET /api/backup/restore/progress`; `_apply_pg_dump_file` (stdin-streamed load with progress, stderr captured to a temp file to avoid pipe deadlock), `_pg_dump_safety_to_local` (safety snapshot with live size), `_download_dropbox_to_file`, `_restore_worker_sync`, `_start_restore` (single restore at a time). The three restore endpoints now start the background job and return `{started: true}`; `_run_pg_restore`/`_apply_pg_dump` (bytes-based) were replaced. + - Local dump retention now sorts by mtime (robust across `pre-restore-…` and downloaded dump names). + - `templates/backup.html`: shared `pollRestoreProgress` with phase labels and percentage/size; the three restore buttons start the job then poll; progress resumes on page reload if a restore is still running. + ## 2026-06-01 — Sidebar version is display-only ### Changed From 0d19365cca9f9e3f403a588b2d42c863944ee892 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Wed, 3 Jun 2026 19:23:32 +0200 Subject: [PATCH 2/2] Reader: Page Up/Down scroll within page instead of switching chapters Chapter navigation stays on the arrow keys. Bump to v0.2.14; reset BUILD=0 for release. Co-Authored-By: Claude Opus 4.8 (1M context) --- containers/novela/changelog.py | 14 ++++++++++++++ containers/novela/templates/reader.html | 5 +++-- containers/novela/version.py | 2 +- docs/changelog-develop.md | 8 ++++++++ docs/changelog.md | 8 ++++++++ 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/containers/novela/changelog.py b/containers/novela/changelog.py index 57ef5b5..5177406 100644 --- a/containers/novela/changelog.py +++ b/containers/novela/changelog.py @@ -3,6 +3,20 @@ Changelog data for Novela """ CHANGELOG = [ + { + "version": "v0.2.14", + "date": "2026-06-03", + "summary": "Reader: Page Up / Page Down now scroll within the page instead of switching chapters — chapter navigation stays on the arrow keys.", + "sections": [ + { + "title": "Improvements", + "type": "improvement", + "changes": [ + "Reader: Page Up and Page Down now scroll within the current page instead of switching chapters. Chapter navigation remains on the Left/Right arrow keys, so accidentally pressing Page Down while reading no longer jumps to the next chapter and loses your place.", + ], + }, + ], + }, { "version": "v0.2.13", "date": "2026-06-01", diff --git a/containers/novela/templates/reader.html b/containers/novela/templates/reader.html index 16ab208..03d7018 100644 --- a/containers/novela/templates/reader.html +++ b/containers/novela/templates/reader.html @@ -701,8 +701,9 @@ // ── Keyboard navigation ──────────────────────────────────────── document.addEventListener('keydown', (e) => { - if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); navigate(1); } - if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); navigate(-1); } + // Arrow keys switch chapters; Page Up/Down are left to the browser for in-page scrolling. + if (e.key === 'ArrowRight') { e.preventDefault(); navigate(1); } + if (e.key === 'ArrowLeft') { e.preventDefault(); navigate(-1); } if (e.key === 'Escape') { closeSettings(); closeBookmarkModal(); } }); diff --git a/containers/novela/version.py b/containers/novela/version.py index 4253908..ab44aca 100644 --- a/containers/novela/version.py +++ b/containers/novela/version.py @@ -10,7 +10,7 @@ from __future__ import annotations from changelog import CHANGELOG -BUILD = 1 +BUILD = 0 def _release_version() -> str: diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md index ab51018..b8fbaad 100644 --- a/docs/changelog-develop.md +++ b/docs/changelog-develop.md @@ -1,5 +1,13 @@ # Develop Changelog +## 2026-06-03 — Reader: Page Up/Down scroll within the page + +### Changed +- In the reader, **Page Up / Page Down no longer switch chapters** — they now fall through to the browser's default in-page scrolling. Chapter navigation is still bound to the **arrow keys** (Left/Right). Previously both the arrow keys and the page keys triggered chapter navigation, so accidentally pressing Page Down while reading jumped to the next chapter and lost the reading position. + - `templates/reader.html`: the `keydown` handler now matches only `ArrowRight`/`ArrowLeft` for `navigate(±1)`; `PageDown`/`PageUp` removed from the bindings. + +*Released as v0.2.14 on 2026-06-03* + ## 2026-06-01 — Full Database Restore: live progress + download-to-local first ### Added diff --git a/docs/changelog.md b/docs/changelog.md index 2622168..92d9b60 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,13 @@ # Changelog +## v0.2.14 — 2026-06-03 + +### Improvements + +- Reader: **Page Up** and **Page Down** now scroll within the current page instead of switching chapters. Chapter navigation remains on the **Left / Right arrow keys**, so accidentally pressing Page Down while reading no longer jumps to the next chapter and loses your place. + +--- + ## v0.2.13 — 2026-06-01 ### New features