Release (merge dev)
This commit is contained in:
commit
1b3278e9fd
@ -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",
|
||||||
|
|||||||
@ -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(
|
||||||
env=env,
|
["psql", *base, "-q", "-f", "-"],
|
||||||
capture_output=True,
|
env=env,
|
||||||
text=True,
|
stdin=subprocess.PIPE,
|
||||||
)
|
stdout=subprocess.DEVNULL,
|
||||||
real_errors = _real_restore_errors(proc.stderr)
|
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:
|
if real_errors:
|
||||||
raise RuntimeError("psql restore failed: " + " | ".join(real_errors)[:500])
|
raise RuntimeError("psql restore failed: " + " | ".join(real_errors)[:500])
|
||||||
finally:
|
if proc.returncode != 0 and not real_errors:
|
||||||
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."
|
|
||||||
)
|
|
||||||
raise RuntimeError(
|
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]:
|
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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user