Release (merge dev)

This commit is contained in:
Ivo Oskamp 2026-06-03 19:23:41 +02:00
commit 1b3278e9fd
6 changed files with 392 additions and 115 deletions

View File

@ -3,6 +3,20 @@ Changelog data for Novela
""" """
CHANGELOG = [ 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", "version": "v0.2.13",
"date": "2026-06-01", "date": "2026-06-01",

View File

@ -5,6 +5,7 @@ import json
import os import os
import shutil import shutil
import subprocess import subprocess
import time
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
@ -45,6 +46,27 @@ BACKUP_TASKS: dict[int, asyncio.Task] = {}
BACKUP_PROGRESS: dict[int, dict] = {} # log_id → {done, total, phase} BACKUP_PROGRESS: dict[int, dict] = {} # log_id → {done, total, phase}
SCHEDULER_TASK: asyncio.Task | None = None 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: def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
@ -781,18 +803,27 @@ def _real_restore_errors(stderr: str) -> list[str]:
return errors return errors
def _apply_pg_dump(dump_bytes: bytes) -> None: def _apply_pg_dump_file(
"""Reset the public schema and load a dump into it. 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 The dump is streamed to psql over stdin in chunks; the byte counter tracks
target server doesn't recognize (e.g. a newer pg_dump emitting how much has been fed, which (thanks to pipe back-pressure) closely follows
`transaction_timeout` against an older server) don't abort the load. psql's actual progress. The load runs WITHOUT ON_ERROR_STOP so benign header
stderr is then inspected: any non-benign `ERROR:` line raises. 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 = os.environ.copy()
env["PGPASSWORD"] = os.environ.get("POSTGRES_PASSWORD", "") env["PGPASSWORD"] = os.environ.get("POSTGRES_PASSWORD", "")
base = _psql_base_args() base = _psql_base_args()
_restore_prog("resetting", 0, 0, reset_label)
reset = subprocess.run( reset = subprocess.run(
["psql", *base, "-v", "ON_ERROR_STOP=1", "-c", "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"], ["psql", *base, "-v", "ON_ERROR_STOP=1", "-c", "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"],
env=env, env=env,
@ -802,64 +833,198 @@ def _apply_pg_dump(dump_bytes: bytes) -> None:
if reset.returncode != 0: if reset.returncode != 0:
raise RuntimeError(f"schema reset failed: {(reset.stderr or '').strip()[:500] or 'unknown error'}") raise RuntimeError(f"schema reset failed: {(reset.stderr or '').strip()[:500] or 'unknown error'}")
with NamedTemporaryFile(suffix=".sql", delete=False) as tmp: _restore_prog(load_phase, 0, total, load_label)
tmp_path = Path(tmp.name) with NamedTemporaryFile(suffix=".stderr", delete=False) as errtmp:
tmp_path.write_bytes(dump_bytes) err_path = Path(errtmp.name)
try: try:
proc = subprocess.run( with err_path.open("wb") as err_fh, path.open("rb") as src:
["psql", *base, "-f", str(tmp_path)], proc = subprocess.Popen(
["psql", *base, "-q", "-f", "-"],
env=env, env=env,
capture_output=True, stdin=subprocess.PIPE,
text=True, stdout=subprocess.DEVNULL,
stderr=err_fh,
) )
real_errors = _real_restore_errors(proc.stderr) 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: if real_errors:
raise RuntimeError("psql restore failed: " + " | ".join(real_errors)[:500]) raise RuntimeError("psql restore failed: " + " | ".join(real_errors)[:500])
if proc.returncode != 0 and not real_errors:
raise RuntimeError(
f"psql exited with code {proc.returncode}: {stderr_text.strip()[:300] or 'unknown error'}"
)
finally: finally:
tmp_path.unlink(missing_ok=True) err_path.unlink(missing_ok=True)
def _run_pg_restore(dump_bytes: bytes) -> None: def _pg_dump_safety_to_local() -> Path:
"""Restore a full PostgreSQL dump, with automatic rollback on failure. """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"
Before the destructive load, a safety dump of the CURRENT database is taken. env = os.environ.copy()
If applying the requested dump fails, the database is rolled back to that env["PGPASSWORD"] = os.environ.get("POSTGRES_PASSWORD", "")
safety snapshot so a failed restore never leaves an empty/broken database. 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
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 def _download_dropbox_to_file(client: dropbox.Dropbox, src: str, dest: Path) -> None:
backups stay Dropbox-only (they can run hourly and would otherwise fill the md, res = client.files_download(src)
disk). 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: try:
safety_bytes, safety_name = _run_pg_dump() # 1. Obtain the dump as a local file.
except Exception: if source == "dropbox":
safety_bytes, safety_name = None, "" client = _dbx()
postgres_root = _dropbox_join(_load_dropbox_root(), "postgres")
if safety_bytes is not None: 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: try:
_save_local_dump(f"pre-restore-{safety_name}", safety_bytes)
_enforce_local_dump_retention(_load_dropbox_retention_count()) _enforce_local_dump_retention(_load_dropbox_retention_count())
except Exception: except Exception:
# A local-copy failure must not block the restore.
pass 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: try:
_apply_pg_dump(dump_bytes) 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: except Exception as restore_err:
if safety_bytes is not None: if safety_path is not None and safety_path.exists():
try: try:
_apply_pg_dump(safety_bytes) _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: except Exception as rollback_err:
raise RuntimeError( RESTORE_PROGRESS.update(
f"restore failed: {restore_err}; AND rollback failed: {rollback_err}. " {"ok": False, "error": f"restore failed: {restore_err}; AND rollback failed: {rollback_err}"}
"Database may be in an inconsistent state."
) )
raise RuntimeError( _restore_prog("error", 0, 0, "Failed")
f"restore failed and was rolled back to the pre-restore state: {restore_err}" return
RESTORE_PROGRESS.update(
{"ok": False, "error": f"restore failed and was rolled back to the pre-restore state: {restore_err}"}
) )
raise _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]: 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) (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]: def _list_local_dumps() -> list[Path]:
if not LOCAL_DUMP_DIR.exists(): if not LOCAL_DUMP_DIR.exists():
return [] return []
dumps = [p for p in LOCAL_DUMP_DIR.glob("*.sql") if p.is_file()] 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: 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"): if not shutil.which("psql"):
return {"ok": False, "error": "psql is not available in this container"} 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: try:
client = await asyncio.to_thread(_dbx) await asyncio.to_thread(_dbx)
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} return {"ok": False, "error": str(e)}
dropbox_root = _load_dropbox_root() started = _start_restore(name=name, source="dropbox", dropbox_name=name)
postgres_root = _dropbox_join(dropbox_root, "postgres") if not started:
dump_path = _dropbox_join(postgres_root, name) return {"ok": False, "error": "A restore is already running."}
return {"ok": True, "started": True, "name": 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)}
@router.get("/api/backup/local/dumps") @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"} return {"ok": False, "error": "Local dump not found"}
if not shutil.which("psql"): if not shutil.which("psql"):
return {"ok": False, "error": "psql is not available in this container"} 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: started = _start_restore(name=path.name, source="local", dump_path=path)
data = await asyncio.to_thread(path.read_bytes) if not started:
except Exception as e: return {"ok": False, "error": "A restore is already running."}
return {"ok": False, "error": f"Failed to read local dump: {e}"} return {"ok": True, "started": True, "name": path.name}
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)}
@router.post("/api/backup/upload-restore") @router.post("/api/backup/upload-restore")
async def upload_restore_dump(file: UploadFile = File(...)): async def upload_restore_dump(file: UploadFile = File(...)):
"""Restore the database from an uploaded .sql dump. No Dropbox token required. """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) The upload is written to a temp file and restored in the background (with
before the load; the uploaded file itself is not persisted. 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"): if not shutil.which("psql"):
return {"ok": False, "error": "psql is not available in this container"} 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 filename = Path(file.filename or "uploaded.sql").name
if not filename.endswith(".sql"): if not filename.endswith(".sql"):
@ -1888,9 +2048,19 @@ async def upload_restore_dump(file: UploadFile = File(...)):
if not data: if not data:
return {"ok": False, "error": "Uploaded file is empty"} return {"ok": False, "error": "Uploaded file is empty"}
try: with NamedTemporaryFile(suffix=".sql", delete=False) as tmp:
await asyncio.to_thread(_run_pg_restore, data) tmp_path = Path(tmp.name)
except Exception as e: tmp_path.write_bytes(data)
return {"ok": False, "error": str(e)}
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)

View File

@ -806,6 +806,61 @@
document.getElementById('btn-pg-restore').disabled = !e.target.value; 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() { async function restorePgDump() {
const name = document.getElementById('pgdump-select').value; const name = document.getElementById('pgdump-select').value;
const out = document.getElementById('pgdump-status'); const out = document.getElementById('pgdump-status');
@ -815,7 +870,7 @@
const btn = document.getElementById('btn-pg-restore'); const btn = document.getElementById('btn-pg-restore');
btn.disabled = true; btn.disabled = true;
out.className = 'status-line warn'; out.className = 'status-line warn';
out.textContent = `Restoring database from ${name}… (do not navigate away)`; out.textContent = 'Starting restore…';
try { try {
const r = await fetch('/api/backup/postgres/restore', { const r = await fetch('/api/backup/postgres/restore', {
method: 'POST', method: 'POST',
@ -823,13 +878,11 @@
body: JSON.stringify({name}), body: JSON.stringify({name}),
}); });
const d = await r.json(); const d = await r.json();
if (!d.ok) throw new Error(d.error || 'failed'); if (!d.ok || !d.started) throw new Error(d.error || 'failed to start');
out.className = 'status-line ok'; await pollRestoreProgress(out, [btn]);
out.textContent = `Database restored from ${d.restored} (${fmtBytes(d.size_bytes)}). Reload the app to see the restored library.`;
} catch (e) { } catch (e) {
out.className = 'status-line err'; out.className = 'status-line err';
out.textContent = `Database restore failed: ${e}`; out.textContent = `Database restore failed: ${e}`;
} finally {
btn.disabled = false; btn.disabled = false;
} }
} }
@ -869,7 +922,7 @@
const btn = document.getElementById('btn-local-restore'); const btn = document.getElementById('btn-local-restore');
btn.disabled = true; btn.disabled = true;
out.className = 'status-line warn'; out.className = 'status-line warn';
out.textContent = `Restoring database from ${name}… (do not navigate away)`; out.textContent = 'Starting restore…';
try { try {
const r = await fetch('/api/backup/local/restore', { const r = await fetch('/api/backup/local/restore', {
method: 'POST', method: 'POST',
@ -877,13 +930,11 @@
body: JSON.stringify({name}), body: JSON.stringify({name}),
}); });
const d = await r.json(); const d = await r.json();
if (!d.ok) throw new Error(d.error || 'failed'); if (!d.ok || !d.started) throw new Error(d.error || 'failed to start');
out.className = 'status-line ok'; await pollRestoreProgress(out, [btn]);
out.textContent = `Database restored from ${d.restored} (${fmtBytes(d.size_bytes)}). Reload the app to see the restored library.`;
} catch (e) { } catch (e) {
out.className = 'status-line err'; out.className = 'status-line err';
out.textContent = `Local restore failed: ${e}`; out.textContent = `Local restore failed: ${e}`;
} finally {
btn.disabled = false; btn.disabled = false;
} }
} }
@ -901,26 +952,40 @@
const btn = document.getElementById('btn-upload-restore'); const btn = document.getElementById('btn-upload-restore');
btn.disabled = true; btn.disabled = true;
out.className = 'status-line warn'; out.className = 'status-line warn';
out.textContent = `Uploading and restoring from ${f.name}… (do not navigate away)`; out.textContent = `Uploading ${f.name}…`;
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append('file', f); fd.append('file', f);
const r = await fetch('/api/backup/upload-restore', {method: 'POST', body: fd}); const r = await fetch('/api/backup/upload-restore', {method: 'POST', body: fd});
const d = await r.json(); const d = await r.json();
if (!d.ok) throw new Error(d.error || 'failed'); if (!d.ok || !d.started) throw new Error(d.error || 'failed to start');
out.className = 'status-line ok';
out.textContent = `Database restored from ${d.restored} (${fmtBytes(d.size_bytes)}). Reload the app to see the restored library.`;
input.value = ''; input.value = '';
await loadLocalDumps(); await pollRestoreProgress(out, [btn]);
} catch (e) { } catch (e) {
out.className = 'status-line err'; out.className = 'status-line err';
out.textContent = `Upload restore failed: ${e}`; out.textContent = `Upload restore failed: ${e}`;
} finally {
btn.disabled = false; 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(); refreshAll();
resumeRestoreIfRunning();
</script> </script>
</body> </body>
</html> </html>

View File

@ -701,8 +701,9 @@
// ── Keyboard navigation ──────────────────────────────────────── // ── Keyboard navigation ────────────────────────────────────────
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); navigate(1); } // Arrow keys switch chapters; Page Up/Down are left to the browser for in-page scrolling.
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); navigate(-1); } if (e.key === 'ArrowRight') { e.preventDefault(); navigate(1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); navigate(-1); }
if (e.key === 'Escape') { closeSettings(); closeBookmarkModal(); } if (e.key === 'Escape') { closeSettings(); closeBookmarkModal(); }
}); });

View File

@ -1,5 +1,24 @@
# Develop Changelog # 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
- 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 ## 2026-06-01 — Sidebar version is display-only
### Changed ### Changed

View File

@ -1,5 +1,13 @@
# Changelog # 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 ## v0.2.13 — 2026-06-01
### New features ### New features