Release (merge dev)
This commit is contained in:
commit
1b3278e9fd
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(); }
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user