Compare commits

..

4 Commits

Author SHA1 Message Date
7a3d5b4ed8 Release v0.2.14 2026-06-03 19:23:42 +02:00
1b3278e9fd Release (merge dev) 2026-06-03 19:23:41 +02:00
0d19365cca 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) <noreply@anthropic.com>
2026-06-03 19:23:32 +02:00
84d95dc886 Dev build 2026-06-01 22:17 2026-06-01 22:17:51 +02:00
6 changed files with 392 additions and 115 deletions

View File

@ -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",

View File

@ -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)

View File

@ -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();
</script>
</body>
</html>

View File

@ -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(); }
});

View File

@ -1,5 +1,24 @@
# 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
### Changed

View File

@ -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