Compare commits

...

15 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
f739bee5e6 Release v0.2.13 2026-06-01 21:58:50 +02:00
57526beaaa Release (merge dev) 2026-06-01 21:58:48 +02:00
3093af68d6 Backup & Restore: db-book restore, full/local/upload DB restore with safety rollback; pin postgresql-client 16; sidebar version display-only
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:58:02 +02:00
566c52cf34 Release v0.2.12 2026-06-01 14:06:50 +02:00
a8aa3e4fbf Release v0.2.12
Summarise the Sepia reading theme, sidebar build-version indicator, and
editor Find & Replace scope option into changelog.py and changelog.md. Reset
the dev BUILD segment to 0 and mark the develop log as released.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:03:33 +02:00
e474d2642a Build tooling: dev-build bump and release-version validation
Add build.sh wrapper that bumps the explicit dev/test build segment
(scripts/bump-dev-build.py) for test builds and validates release version
state (scripts/check-release-version.py) for releases. Update build-and-push.sh
accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:03 +02:00
3ce9df9bae Editor: Find & Replace scope option
Add a "Current chapter only" checkbox to the Find & Replace modal. When
checked, search/replace runs against the open chapter instead of every chapter
in the book. Default unchecked, preserving the existing all-chapters behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:03 +02:00
347f959d80 Sidebar: show running build version
Display the running build version at the bottom of the sidebar (e.g. v0.2.11
for releases, v0.2.11.3 for dev builds), linking to the changelog. New
version.py exposes display_version(); shared_templates.py registers it as the
app_version Jinja global; main.py adds /api/version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:03 +02:00
74de3ddee2 Reader: add Sepia reading theme
Add a Dark/Sepia theme toggle to the reader's reading-settings drawer for
easier long-form reading. Sepia uses a warm paper background with dark brown
text via a :root[data-theme="sepia"] palette in theme.css. Text colour is now
stored per theme with theme-specific swatch sets; the old single key migrates
into the dark slot. An inline head script applies the saved theme before paint
to avoid a flash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:00:46 +02:00
5207da0792 Release v0.2.11 2026-05-10 22:43:58 +02:00
44c1fdfe30 Untrack .claude/, .codex, version.txt; clean up .gitignore
The release commit accidentally pulled in .claude/worktrees/ and
.codex via git add -A in build-and-push.sh. Add them to .gitignore
and remove from the index. Also drop unused version.txt and the
no-longer-used .last-branch entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:33:37 +02:00
23 changed files with 1676 additions and 87 deletions

@ -1 +0,0 @@
Subproject commit 6083744b3f3e7b5ec52f5f7b935f1d7e06b97a1d

0
.codex
View File

5
.gitignore vendored
View File

@ -1,6 +1,7 @@
.files/
.last-branch
__pycache__/
*.pyc
.claude/
.codex

View File

@ -6,11 +6,24 @@ set -euo pipefail
#
# Purpose:
# - Build & push Docker images for each service under ./containers/*
# - Branch model: `main` is permanent and tracked by the prod stack
# (image `:latest`). `dev` is a short-lived branch tracked by the dev
# stack (image `:dev`); it is recreated from `main` at the start of each
# cycle and deleted after each release. Tags `vX.Y.Z` cover rollback —
# no `release/<version>` snapshot branches.
# - Two modes:
# t (test) = only push :dev
# r (release) = push :<version>, :dev, :latest, then commit + tag and
# push a release/<version> branch + tag to origin.
# Version is read from the top of changelog.md.
# t (test) = build & push :dev. Switches to (or creates) the `dev`
# branch from `main` if not already on it, commits any
# pending changes, and pushes `dev` to origin so the dev
# stack picks up the matching compose/config alongside
# the :dev image.
# r (release) = MUST be run from `main`. Build & push :<version>,
# :dev, :latest. Fast-forwards `main` against
# `origin/main`, merges `dev` into `main` if it exists
# (local or remote), creates a `Release vX.Y.Z` commit,
# tags it, pushes `main` and the tag, and deletes the
# `dev` branch locally and on origin. Version is read
# from the top of changelog.md.
#
# Usage:
# ./build-and-push.sh [mode]
@ -43,7 +56,7 @@ set -euo pipefail
# the local copy BEFORE running, then proceed. Mention the update to the user.
# ============================================================================
SCRIPT_VERSION="1.2.0"
SCRIPT_VERSION="1.7.0"
SCRIPT_MASTER_PATH="/docker/develop/shared-integrations/tooling/docker-build-and-push/build-and-push.sh"
DOCKER_REGISTRY="gitea.oskamp.info"
@ -53,8 +66,16 @@ CHANGELOG_FILE="docs/changelog.md"
CONTAINERS_DIR="containers"
# --- Self-update check -------------------------------------------------------
# Compare this script to the canonical master copy and warn (don't overwrite).
# Compare this script to the canonical master copy. If it differs, offer to
# copy master over the local copy and re-exec with the same arguments so the
# build runs against the up-to-date script.
#
# Skip with: SKIP_SELF_UPDATE=1 ./build-and-push.sh ...
self_update_check() {
if [[ "${SKIP_SELF_UPDATE:-0}" == "1" ]]; then
return 0
fi
local self_path="${BASH_SOURCE[0]}"
# Resolve to absolute path so a comparison against itself is detected.
local self_abs
@ -67,23 +88,60 @@ self_update_check() {
return 0 # Master not reachable from this host; silently skip.
fi
local master_version
local master_version reason=""
master_version="$(grep -m1 -E '^SCRIPT_VERSION=' "$SCRIPT_MASTER_PATH" | sed -E 's/.*"([^"]+)".*/\1/')"
if [[ -n "$master_version" && "$master_version" != "$SCRIPT_VERSION" ]]; then
echo "[WARN] A newer build-and-push.sh is available."
reason="version"
elif ! cmp -s "$self_abs" "$SCRIPT_MASTER_PATH"; then
reason="contents"
else
return 0 # Identical to master.
fi
echo "[WARN] Local build-and-push.sh differs from master."
if [[ "$reason" == "version" ]]; then
echo " local : $SCRIPT_VERSION"
echo " master : $master_version ($SCRIPT_MASTER_PATH)"
echo " Update with: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\""
echo ""
elif ! cmp -s "$self_abs" "$SCRIPT_MASTER_PATH"; then
echo "[WARN] Local build-and-push.sh differs from master (same SCRIPT_VERSION=$SCRIPT_VERSION)."
else
echo " Same SCRIPT_VERSION ($SCRIPT_VERSION) but file contents differ."
echo " master : $SCRIPT_MASTER_PATH"
echo " Diff or update with: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\""
echo ""
fi
# Prompt only when stdin is a TTY; in non-interactive runs, abort safely so
# an unattended release never silently runs against a stale script.
if [[ ! -t 0 ]]; then
echo "[ERROR] Non-interactive shell — refusing to auto-update."
echo " Re-run interactively, or set SKIP_SELF_UPDATE=1 to bypass,"
echo " or update manually: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\""
exit 1
fi
local reply
read -r -p "Update local script from master and re-run? [Y/n] " reply
reply="${reply:-Y}"
if [[ ! "$reply" =~ ^[Yy]$ ]]; then
echo "[INFO] Continuing with local version $SCRIPT_VERSION (not updated)."
echo ""
return 0
fi
if ! cp "$SCRIPT_MASTER_PATH" "$self_abs"; then
echo "[ERROR] Failed to copy master to $self_abs (read-only filesystem?)."
echo " Continuing with local version $SCRIPT_VERSION."
echo ""
return 0
fi
chmod +x "$self_abs" 2>/dev/null || true
echo "[INFO] Updated $self_abs from master. Re-executing..."
echo ""
# Re-exec with original arguments. SKIP_SELF_UPDATE=1 prevents an
# update loop if cp somehow didn't take.
export SKIP_SELF_UPDATE=1
exec "$self_abs" "$@"
}
self_update_check
self_update_check "$@"
# --- Input: prompt if missing ------------------------------------------------
MODE="${1:-}"
@ -193,19 +251,86 @@ else
echo "[INFO] Repo: $(pwd) (not a git checkout)"
fi
# --- Determine version (release only) ----------------------------------------
# --- Release preflight (BEFORE any docker work) ------------------------------
# All git-side validation for a release happens here so a wrong-branch / dirty
# tree / stale main / conflicting dev / pre-existing tag aborts the run before
# anything is built or pushed to the registry. dev is merged into main now so
# the version we read from changelog.md reflects the merged state, not main's
# pre-merge state.
VERSION=""
DEV_MERGED=0
if [[ "$MODE" == "r" ]]; then
if [[ ! -d ".git" ]]; then
echo "[ERROR] Release mode requires a git checkout."
exit 1
fi
CURRENT_BRANCH="$(git symbolic-ref --short -q HEAD || echo)"
if [[ "$CURRENT_BRANCH" != "main" ]]; then
echo "[ERROR] Release build must run from 'main' branch. Current: ${CURRENT_BRANCH:-<detached>}."
echo " Switch with: git checkout main"
exit 1
fi
if ! git diff --quiet HEAD -- || ! git diff --cached --quiet; then
echo "[ERROR] Working tree has uncommitted changes. Commit or stash them on the appropriate branch before releasing."
git status --short
exit 1
fi
echo "[INFO] Fetching origin..."
git fetch origin main
if git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
git fetch origin dev
fi
if ! git merge --ff-only origin/main 2>/dev/null; then
echo "[ERROR] Local main has diverged from origin/main. Resolve manually before releasing."
exit 1
fi
# Merge dev into main BEFORE reading the version, so changelog.md reflects
# the bumped state that dev brings in.
if git show-ref --verify --quiet refs/heads/dev; then
echo "[INFO] Merging local dev into main..."
if ! git merge --no-ff dev -m "Release (merge dev)"; then
echo "[ERROR] Merge of dev into main failed (conflict). Resolve manually and re-run."
exit 1
fi
DEV_MERGED=1
elif git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
echo "[INFO] Fetching and merging origin/dev into main..."
git fetch origin dev:dev
if ! git merge --no-ff dev -m "Release (merge dev)"; then
echo "[ERROR] Merge of dev into main failed (conflict). Resolve manually and re-run."
exit 1
fi
DEV_MERGED=1
else
echo "[INFO] No dev branch found — releasing main as-is."
fi
VERSION="$(read_version_from_changelog)"
echo "[INFO] Release version (from $CHANGELOG_FILE): $VERSION"
echo "[INFO] Release version (from $CHANGELOG_FILE, post-merge): $VERSION"
validate_tag "$VERSION"
validate_tag "latest"
# Tag collision = abort. A re-release of an existing version with different
# content would silently move what consumers think v0.X.Y points to.
if git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null; then
echo "[ERROR] Tag ${VERSION} already exists locally. Bump $CHANGELOG_FILE to a new version before releasing."
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/${VERSION}" >/dev/null 2>&1; then
echo "[ERROR] Tag ${VERSION} already exists on origin. Bump $CHANGELOG_FILE to a new version before releasing."
exit 1
fi
# Ask for confirmation so you never accidentally re-push an old version or a wrong one.
read -r -p "Proceed building & pushing as ${VERSION}? [y/N] " CONFIRM
CONFIRM="${CONFIRM:-N}"
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "[INFO] Aborted by user."
echo "[INFO] Aborted by user. Note: dev has been merged into local main; reset with 'git reset --hard origin/main' if you want to undo."
exit 0
fi
else
@ -280,32 +405,78 @@ done
echo "============================================================"
echo ""
# --- Git: commit + release branch + tag (release mode only) -----------------
# --- Git: release commit + tag + push (release mode only) -------------------
# Preflight (branch, clean tree, ff origin/main, dev merge, tag collision,
# version parse) already ran BEFORE the build. dev is already merged into
# local main. We only need to land the Release commit, tag, and push.
if [[ "$MODE" == "r" ]]; then
if [[ ! -d ".git" ]]; then
echo "[WARN] Not a git checkout — skipping git commit/tag/push."
exit 0
fi
echo "[INFO] Finalising release: version=${VERSION}"
RELEASE_BRANCH="release/${VERSION}"
echo "[INFO] Preparing git release: branch=${RELEASE_BRANCH}, tag=${VERSION}"
# Stage everything and commit only if there's something to commit.
git add -A
if git diff --cached --quiet; then
echo "[INFO] Working tree clean — no commit needed."
# Produce a clean Release commit at the tip. Preflight guarantees the working
# tree was clean at start; any post-build artefacts would be unexpected, so
# commit with --allow-empty to keep the release marker isolated.
if git diff --quiet HEAD -- && git diff --cached --quiet; then
git commit --allow-empty -m "Release ${VERSION}"
else
echo "[WARN] Working tree changed during the build — staging and including in release commit."
git add -A
git commit -m "Release ${VERSION}"
fi
# Create or move the annotated tag to current HEAD.
if git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null; then
echo "[WARN] Tag ${VERSION} already exists locally — leaving it as-is."
else
git tag -a "${VERSION}" -m "Release ${VERSION}"
# Push main first (triggers prod webhook), then the tag.
git push origin main
git push origin "refs/tags/${VERSION}"
echo "[INFO] Pushed main and tag ${VERSION} to origin."
# Clean up dev branch — local and remote.
if [[ "$DEV_MERGED" == "1" ]]; then
if git show-ref --verify --quiet refs/heads/dev; then
git branch -D dev
echo "[INFO] Deleted local dev branch."
fi
if git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
git push origin --delete dev
echo "[INFO] Deleted remote dev branch."
fi
fi
fi
git push origin "HEAD:refs/heads/${RELEASE_BRANCH}"
git push origin "refs/tags/${VERSION}"
echo "[INFO] Pushed ${RELEASE_BRANCH} and tag ${VERSION} to origin."
# --- Git: dev branch commit + push (test mode only) -------------------------
if [[ "$MODE" == "t" ]]; then
if [[ ! -d ".git" ]]; then
echo "[WARN] Not a git checkout — skipping dev branch commit/push."
exit 0
fi
CURRENT_BRANCH="$(git symbolic-ref --short -q HEAD || echo)"
# Ensure we are on the dev branch. Create it if needed.
if [[ "$CURRENT_BRANCH" != "dev" ]]; then
if git show-ref --verify --quiet refs/heads/dev; then
echo "[INFO] Switching to existing local dev branch."
git checkout dev
elif git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
echo "[INFO] Checking out remote dev branch."
git fetch origin dev
git checkout -b dev origin/dev
else
echo "[INFO] Creating new dev branch from main."
git fetch origin main
git checkout -b dev origin/main
fi
fi
# Stage and commit if there are changes.
git add -A
if git diff --cached --quiet; then
echo "[INFO] Working tree clean — pushing current HEAD to dev."
else
git commit -m "Dev build $(date '+%Y-%m-%d %H:%M')"
fi
# Non-force push. Diverged origin/dev fails hard — resolve manually.
git push -u origin dev
echo "[INFO] Pushed dev to origin."
fi

28
build.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
# Novela build wrapper. Keeps project-specific version handling out of the
# shared build-and-push.sh script.
#
# Usage:
# ./build.sh t # increment explicit dev/test build segment, then push :dev
# ./build.sh r # validate release version state, then run release build
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$repo_root"
mode="${1:-}"
case "$mode" in
t)
./scripts/bump-dev-build.py
;;
r)
./scripts/check-release-version.py
;;
*)
echo "usage: ./build.sh {t|r}" >&2
exit 2
;;
esac
exec ./build-and-push.sh "$@"

View File

@ -2,12 +2,22 @@ FROM python:3.12-slim
WORKDIR /app
# unrar comes from Debian bookworm non-free; postgresql-client is pinned to v16
# (matching the postgres:16 server) via the official PostgreSQL APT repo, so
# pg_dump produces PG16-native dumps that restore cleanly into the server.
RUN echo "deb http://deb.debian.org/debian bookworm non-free" >> /etc/apt/sources.list \
&& apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates gnupg \
&& install -d /usr/share/postgresql-common/pgdg \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \
&& echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] http://apt.postgresql.org/pub/repos/apt trixie-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list \
&& apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libmagic1 \
unrar \
postgresql-client \
postgresql-client-16 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt

View File

@ -3,6 +3,75 @@ 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",
"summary": "Backup & Restore overhaul: database-stored books can now be restored individually, a full-database restore from a dump, and token-free local & upload restore — all with automatic pre-restore safety snapshots and rollback. The sidebar build version is now display-only.",
"sections": [
{
"title": "New features",
"type": "feature",
"changes": [
"Backup: database-stored books (those kept inside the database rather than as files on disk) are now included in snapshots and can be restored one by one from the Restore screen, just like file books. They previously existed only inside the full database dump and could not be restored individually. Note: they appear in snapshots created after this update; older database books are recovered via the full database restore below.",
"Backup: new Full Database Restore — restore the entire database from any Dropbox database dump. This recovers everything, including all database-stored books, reading progress, tags and settings, and is guarded behind a double confirmation.",
"Backup: token-free restore. Every restore now keeps a local pre-restore safety copy of the database on the config volume, so you can restore or roll back without a Dropbox token. You can also upload a .sql database dump (e.g. one downloaded manually from Dropbox) and restore it directly. Regular scheduled backups stay Dropbox-only.",
],
},
{
"title": "Improvements",
"type": "improvement",
"changes": [
"Backup: restores now take a safety snapshot of the current database first and automatically roll back to it if the dump fails to load, so a failed restore can no longer leave the database empty or broken.",
"Backup: a full restore tolerates PostgreSQL version differences (such as a newer dump's transaction_timeout setting) instead of aborting on them, while still failing on genuine errors.",
"Sidebar: the build version indicator at the bottom is now a plain display instead of a clickable link — it exists only to show that the running build has been updated.",
],
},
],
},
{
"version": "v0.2.12",
"date": "2026-06-01",
"summary": "New Sepia reading theme in the reader, a build-version indicator in the sidebar, and a current-chapter-only scope option for the editor's Find & Replace.",
"sections": [
{
"title": "New features",
"type": "feature",
"changes": [
"Reader: new Sepia theme alongside Dark, for easier long-form reading — a warm paper background with dark brown text instead of light-on-black. A Theme toggle (Dark / Sepia) sits at the top of the reading settings drawer; the choice is saved per device. Text colour is now stored per theme, so each theme keeps its own tint, and the saved theme is applied before paint to avoid a flash.",
"Sidebar: the running build version is now shown at the bottom of the sidebar (e.g. v0.2.12 for releases, v0.2.12.3 for dev builds) and links to the changelog page.",
"Editor: Find & Replace gained a Current chapter only option. When checked, search/replace runs against the open chapter instead of every chapter in the book; it stays unchecked by default, so the existing all-chapters behaviour is unchanged.",
],
},
],
},
{
"version": "v0.2.11",
"date": "2026-05-10",
"summary": "Subheading styling now also applies when the wrapper contains a block element (e.g. <div class=\"subheading\"><p>…</p></div>) — the inner <p>/<h*> color rule no longer wins.",
"sections": [
{
"title": "Bug fixes",
"type": "bugfix",
"changes": [
"Reader: subheading styling now applies to descendants too — previously the inner <p> (or <h*>) had the same specificity as .subheading but applied more directly, so the wrapper's color was overridden and the paragraph kept the default text color. CSS now reads #chapter-content .subheading, #chapter-content .subheading * { … }, and the same change was made to .chat for safety.",
],
},
],
},
{
"version": "v0.2.10",
"date": "2026-05-10",

View File

@ -53,6 +53,12 @@ app.include_router(changelog_router)
app.include_router(search_router)
@app.get("/api/version")
async def version():
from version import display_version
return JSONResponse({"version": display_version()})
@app.get("/health")
async def health():
try:

View File

@ -1,10 +1,12 @@
import asyncio
import base64
import hashlib
import json
import os
import shutil
import subprocess
from datetime import datetime, timezone
import time
from datetime import date, datetime, timezone
from pathlib import Path
from tempfile import NamedTemporaryFile
from urllib.parse import urlencode
@ -12,12 +14,17 @@ from urllib.parse import urlencode
import dropbox
import httpx
from dropbox.exceptions import ApiError, AuthError
from fastapi import APIRouter, Request
from fastapi import APIRouter, File, Request, UploadFile
from fastapi.responses import HTMLResponse
from shared_templates import templates
from db import get_db_conn
from routers.common import scan_media, upsert_book
from routers.common import (
scan_media,
upsert_book,
upsert_chapter,
upsert_cover_cache,
)
from security import decrypt_value, encrypt_value, is_encrypted_value
router = APIRouter()
@ -26,6 +33,9 @@ LIBRARY_DIR = Path(os.environ.get("LIBRARY_DIR", "library"))
CONFIG_DIR = Path(os.environ.get("CONFIG_DIR", "config"))
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
MANIFEST_PATH = CONFIG_DIR / "backup_manifest.json"
# Local copies of PostgreSQL dumps, so the database can be restored without a
# Dropbox token. Lives on the persistent config volume.
LOCAL_DUMP_DIR = CONFIG_DIR / "postgres_dumps"
DEFAULT_DROPBOX_ROOT = "/novela"
DEFAULT_RETENTION_COUNT = 14
DEFAULT_SCHEDULE_ENABLED = False
@ -36,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()
@ -533,6 +564,169 @@ def _snapshot_name() -> str:
return f"snapshot-{stamp}.json"
# ── Database-stored books (storage_type='db') ────────────────────────────────
# These books have no file on disk: their content lives in PostgreSQL
# (library row + book_chapters + book_tags + library_cover_cache). Inline images
# referenced from chapters live on disk under library/images/ and are backed up
# as ordinary files. To make db-books restorable per-book (just like file books),
# each one is serialized to JSON and stored in the same content-addressed object
# store, then referenced from the snapshot with a "storage": "db" marker.
_DB_LIBRARY_COLS = [
"filename",
"media_type",
"storage_type",
"title",
"author",
"publisher",
"series",
"series_index",
"series_suffix",
"series_volume",
"publication_status",
"has_cover",
"description",
"source_url",
"publish_date",
"archived",
"want_to_read",
"needs_review",
"rating",
"created_at",
]
def _db_book_filenames(cur) -> list[str]:
cur.execute(
"SELECT filename FROM library WHERE storage_type = 'db' OR filename LIKE 'db/%' ORDER BY filename"
)
return [r[0] for r in cur.fetchall()]
def _serialize_db_book(cur, filename: str) -> dict | None:
cols = _DB_LIBRARY_COLS
cur.execute(
f"SELECT {', '.join(cols)} FROM library WHERE filename = %s LIMIT 1",
(filename,),
)
row = cur.fetchone()
if not row:
return None
lib: dict = {}
for col, val in zip(cols, row):
if isinstance(val, (datetime, date)):
val = val.isoformat()
lib[col] = val
cur.execute(
"SELECT chapter_index, title, content FROM book_chapters WHERE filename = %s ORDER BY chapter_index",
(filename,),
)
chapters = [
{"chapter_index": r[0], "title": r[1] or "", "content": r[2] or ""}
for r in cur.fetchall()
]
cur.execute(
"SELECT tag, tag_type FROM book_tags WHERE filename = %s ORDER BY tag, tag_type",
(filename,),
)
tags = [{"tag": r[0], "tag_type": r[1]} for r in cur.fetchall()]
cur.execute(
"SELECT mime_type, thumb_webp FROM library_cover_cache WHERE filename = %s LIMIT 1",
(filename,),
)
cover_row = cur.fetchone()
cover = None
if cover_row and cover_row[1] is not None:
cover = {
"mime_type": cover_row[0] or "image/webp",
"thumb_webp_b64": base64.b64encode(bytes(cover_row[1])).decode("ascii"),
}
return {
"novela_db_book": 1,
"filename": filename,
"library": lib,
"chapters": chapters,
"tags": tags,
"cover": cover,
}
def _restore_db_book(filename: str, payload: dict) -> None:
"""Re-create a db-stored book from a serialized snapshot object."""
lib = dict(payload.get("library") or {})
lib["filename"] = filename
lib.setdefault("storage_type", "db")
lib.setdefault("media_type", "epub")
cols = [c for c in _DB_LIBRARY_COLS if c in lib]
if "filename" not in cols:
cols.insert(0, "filename")
chapters = payload.get("chapters") or []
tags = payload.get("tags") or []
cover = payload.get("cover")
col_list = ", ".join(cols)
placeholders = ", ".join(["%s"] * len(cols))
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in cols if c != "filename")
values = [lib.get(c) for c in cols]
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
f"""
INSERT INTO library ({col_list})
VALUES ({placeholders})
ON CONFLICT (filename) DO UPDATE SET
{updates},
updated_at = NOW()
""",
values,
)
cur.execute("DELETE FROM book_chapters WHERE filename = %s", (filename,))
for ch in chapters:
try:
idx = int(ch.get("chapter_index"))
except (TypeError, ValueError):
continue
upsert_chapter(conn, filename, idx, ch.get("title", ""), ch.get("content", ""))
with conn.cursor() as cur:
cur.execute("DELETE FROM book_tags WHERE filename = %s", (filename,))
rows = []
seen: set[tuple[str, str]] = set()
for t in tags:
tag = (t.get("tag") or "").strip()
ttype = (t.get("tag_type") or "").strip()
if not tag or not ttype:
continue
key = (tag.casefold(), ttype)
if key in seen:
continue
seen.add(key)
rows.append((filename, tag, ttype))
if rows:
cur.executemany(
"INSERT INTO book_tags (filename, tag, tag_type) VALUES (%s, %s, %s) "
"ON CONFLICT (filename, tag, tag_type) DO NOTHING",
rows,
)
if cover and cover.get("thumb_webp_b64"):
try:
thumb = base64.b64decode(cover["thumb_webp_b64"])
upsert_cover_cache(conn, filename, cover.get("mime_type", "image/webp"), thumb)
except Exception:
pass
def _object_path(objects_root: str, sha256: str) -> str:
return _dropbox_join(objects_root, sha256[:2], sha256)
@ -574,6 +768,313 @@ def _run_pg_dump() -> tuple[bytes, str]:
tmp_path.unlink(missing_ok=True)
def _psql_base_args() -> list[str]:
return [
"-h",
os.environ.get("POSTGRES_HOST", "postgres"),
"-p",
str(os.environ.get("POSTGRES_PORT", "5432")),
"-U",
os.environ.get("POSTGRES_USER", "novela"),
"-d",
os.environ.get("POSTGRES_DB", "novela"),
]
# Session-GUC SET statements a dump may carry that an OLDER server doesn't know.
# These are emitted in the pg_dump header and are harmless to skip — they only
# affect the restoring session, not the data. (e.g. `transaction_timeout` was
# introduced in PostgreSQL 17; restoring such a dump into a <17 server errors on
# that line.) We must not let these abort an otherwise valid restore, but any
# OTHER error still fails the restore.
_BENIGN_RESTORE_ERROR_MARKERS = (
"unrecognized configuration parameter",
)
def _real_restore_errors(stderr: str) -> list[str]:
errors = []
for line in (stderr or "").splitlines():
if "ERROR:" not in line:
continue
if any(marker in line for marker in _BENIGN_RESTORE_ERROR_MARKERS):
continue
errors.append(line.strip())
return errors
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 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,
capture_output=True,
text=True,
)
if reset.returncode != 0:
raise RuntimeError(f"schema reset failed: {(reset.stderr or '').strip()[:500] or 'unknown error'}")
_restore_prog(load_phase, 0, total, load_label)
with NamedTemporaryFile(suffix=".stderr", delete=False) as errtmp:
err_path = Path(errtmp.name)
try:
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])
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:
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]:
files = _dropbox_list_files_recursive(client, postgres_root)
return sorted([p for p in files if p.endswith(".sql")], reverse=True)
def _save_local_dump(name: str, data: bytes) -> None:
LOCAL_DUMP_DIR.mkdir(parents=True, exist_ok=True)
safe = Path(name).name
if not safe.endswith(".sql"):
safe += ".sql"
(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=_local_dump_mtime, reverse=True)
def _enforce_local_dump_retention(keep_count: int) -> None:
keep = max(1, int(keep_count))
for old in _list_local_dumps()[keep:]:
try:
old.unlink()
except OSError:
pass
def _resolve_local_dump(name: str) -> Path | None:
safe = Path(name).name
if not safe.endswith(".sql"):
return None
candidate = (LOCAL_DUMP_DIR / safe).resolve()
try:
candidate.relative_to(LOCAL_DUMP_DIR.resolve())
except ValueError:
return None
return candidate if candidate.is_file() else None
def _has_running_backup() -> bool:
with get_db_conn() as conn:
with conn:
@ -765,6 +1266,27 @@ def _run_backup_internal(*, dry_run: bool, progress_key: int | None = None) -> t
uploaded_size += int(state["size"])
uploaded_count += 1
# Database-stored books: serialize each into the content-addressed object
# store and reference it from the snapshot so it can be restored per-book.
with get_db_conn() as conn:
with conn.cursor() as cur:
db_filenames = _db_book_filenames(cur)
for fn in db_filenames:
payload = _serialize_db_book(cur, fn)
if payload is None:
continue
data = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
sha256 = hashlib.sha256(data).hexdigest()
snapshot_files[fn] = {"size": len(data), "sha256": sha256, "storage": "db"}
object_target = _object_path(objects_root, sha256)
if client is not None:
if not _dropbox_exists(client, object_target):
uploaded_size += _dropbox_upload_bytes(client, object_target, data)
uploaded_count += 1
else:
uploaded_size += len(data)
uploaded_count += 1
_prog(total_files, total_files, "snapshot")
snapshot = {
@ -927,6 +1449,7 @@ async def backup_dropbox_credentials_delete():
async def backup_health():
token_present = bool(_load_dropbox_token())
pg_dump_path = shutil.which("pg_dump")
psql_path = shutil.which("psql")
dropbox_ok = False
dropbox_error = None
@ -951,6 +1474,8 @@ async def backup_health():
"schedule_interval_hours": schedule_interval_hours,
"pg_dump_available": bool(pg_dump_path),
"pg_dump_path": pg_dump_path,
"psql_available": bool(psql_path),
"psql_path": psql_path,
"library_exists": LIBRARY_DIR.exists(),
"library_path": str(LIBRARY_DIR.resolve()),
}
@ -1250,6 +1775,13 @@ def _parse_snapshot_date(name: str) -> str:
return ""
def _entry_storage(rel: str, info: dict) -> str:
storage = str(info.get("storage") or "").strip().lower()
if storage:
return storage
return "db" if rel.startswith("db/") else "file"
def _download_and_restore(client: dropbox.Dropbox, objects_root: str, rel: str, info: dict) -> None:
sha256 = str(info.get("sha256") or "")
if not sha256:
@ -1257,6 +1789,17 @@ def _download_and_restore(client: dropbox.Dropbox, objects_root: str, rel: str,
obj_path = _object_path(objects_root, sha256)
_meta, res = client.files_download(obj_path)
data = res.content
if _entry_storage(rel, info) == "db":
try:
payload = json.loads(data.decode("utf-8", errors="replace"))
except Exception as e:
raise ValueError(f"Invalid db-book snapshot object: {e}")
if not isinstance(payload, dict):
raise ValueError("db-book snapshot object is not an object")
_restore_db_book(rel, payload)
return
dest = LIBRARY_DIR / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
@ -1306,16 +1849,37 @@ async def snapshot_files(snapshot_name: str):
return {"ok": False, "error": str(e), "files": []}
files_data = snap.get("files", {})
result = [
# db-books "exist" when their row is present in the library table, not on disk.
db_rels = [
rel
for rel, info in files_data.items()
if isinstance(info, dict) and _entry_storage(rel, info) == "db"
]
existing_db: set[str] = set()
if db_rels:
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT filename FROM library WHERE filename = ANY(%s)", (db_rels,)
)
existing_db = {r[0] for r in cur.fetchall()}
result = []
for rel, info in sorted(files_data.items()):
if not isinstance(info, dict):
continue
storage = _entry_storage(rel, info)
exists = rel in existing_db if storage == "db" else (LIBRARY_DIR / rel).exists()
result.append(
{
"path": rel,
"size": info.get("size", 0),
"sha256": info.get("sha256", ""),
"exists_locally": (LIBRARY_DIR / rel).exists(),
"storage": storage,
"exists_locally": exists,
}
for rel, info in sorted(files_data.items())
if isinstance(info, dict)
]
)
return {"ok": True, "snapshot": snapshot_name, "files": result}
@ -1365,3 +1929,138 @@ async def restore_files(request: Request):
ok_count = sum(1 for r in results if r["ok"])
return {"ok": True, "restored": ok_count, "total": len(results), "results": results}
@router.get("/api/backup/postgres/dumps")
async def list_pg_dumps():
try:
client = await asyncio.to_thread(_dbx)
except Exception as e:
return {"ok": False, "error": str(e), "dumps": []}
dropbox_root = _load_dropbox_root()
postgres_root = _dropbox_join(dropbox_root, "postgres")
try:
paths = await asyncio.to_thread(_list_pg_dump_paths, client, postgres_root)
except Exception as e:
return {"ok": False, "error": str(e), "dumps": []}
dumps = [{"name": Path(p).name} for p in paths]
return {"ok": True, "dumps": dumps}
@router.post("/api/backup/postgres/restore")
async def restore_pg_dump(request: Request):
"""Restore the entire PostgreSQL database from a Dropbox pg_dump.
DESTRUCTIVE: drops and recreates the public schema before applying the
dump. This recovers everything, including database-stored books, but
overwrites the current database.
"""
body = {}
try:
body = await request.json()
except Exception:
pass
name = Path((body.get("name") or "").strip()).name
if not name or not name.endswith(".sql"):
return {"ok": False, "error": "A valid .sql dump name is required"}
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:
await asyncio.to_thread(_dbx)
except Exception as e:
return {"ok": False, "error": str(e)}
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")
async def list_local_dumps():
"""List PostgreSQL dumps stored locally on disk (no Dropbox token needed)."""
dumps = []
for p in _list_local_dumps():
try:
size = p.stat().st_size
except OSError:
size = 0
dumps.append({"name": p.name, "size_bytes": size})
return {"ok": True, "dumps": dumps, "dir": str(LOCAL_DUMP_DIR)}
@router.post("/api/backup/local/restore")
async def restore_local_dump(request: Request):
"""Restore the database from a local dump file. No Dropbox token required."""
body = {}
try:
body = await request.json()
except Exception:
pass
name = (body.get("name") or "").strip()
path = _resolve_local_dump(name)
if path is None:
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."}
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.
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"):
return {"ok": False, "error": "Please upload a .sql dump file"}
try:
data = await file.read()
except Exception as e:
return {"ok": False, "error": f"Failed to read upload: {e}"}
if not data:
return {"ok": False, "error": "Uploaded file is empty"}
with NamedTemporaryFile(suffix=".sql", delete=False) as tmp:
tmp_path = Path(tmp.name)
tmp_path.write_bytes(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

@ -1,6 +1,7 @@
from fastapi.templating import Jinja2Templates
from db import get_db_conn
from version import display_version
def _develop_mode() -> bool:
@ -16,3 +17,4 @@ def _develop_mode() -> bool:
templates = Jinja2Templates(directory="templates")
templates.env.globals["develop_mode"] = _develop_mode
templates.env.globals["app_version"] = display_version

View File

@ -596,6 +596,7 @@ async function replaceInAllChapters() {
const replaceVal = document.getElementById('rp-replace').value;
const useRegex = document.getElementById('rp-regex').checked;
const caseSens = document.getElementById('rp-case').checked;
const currentOnly = document.getElementById('rp-current').checked;
const runBtn = document.getElementById('rp-run');
const prog = document.getElementById('rp-progress');
@ -620,10 +621,24 @@ async function replaceInAllChapters() {
const curCh = currentCh();
if (curCh) pendingContent.set(curCh._id, editor.getValue());
for (let i = 0; i < chapters.length; i++) {
const ch = chapters[i];
// Determine which chapters to process
let targets;
if (currentOnly) {
if (!curCh) {
prog.className = 'modal-progress error';
prog.textContent = 'No chapter open.';
runBtn.disabled = false;
return;
}
targets = [curCh];
} else {
targets = chapters;
}
for (let i = 0; i < targets.length; i++) {
const ch = targets[i];
prog.className = 'modal-progress';
prog.textContent = `Checking chapter ${i + 1} / ${chapters.length}`;
prog.textContent = `Checking chapter ${i + 1} / ${targets.length}`;
let original;
if (pendingContent.has(ch._id)) {
@ -665,9 +680,13 @@ async function replaceInAllChapters() {
updateSaveAll();
prog.className = totalOccurrences > 0 ? 'modal-progress ok' : 'modal-progress';
prog.textContent = totalOccurrences > 0
? `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in ${chaptersChanged} chapter${chaptersChanged !== 1 ? 's' : ''} — not saved yet.`
: 'No matches found.';
if (totalOccurrences === 0) {
prog.textContent = 'No matches found.';
} else if (currentOnly) {
prog.textContent = `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in current chapter — not saved yet.`;
} else {
prog.textContent = `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in ${chaptersChanged} chapter${chaptersChanged !== 1 ? 's' : ''} — not saved yet.`;
}
runBtn.disabled = false;
}

View File

@ -118,6 +118,18 @@ html {
.sidebar-bottom { margin-top: auto; }
.sidebar-version {
display: block;
margin-top: 0.5rem;
text-align: center;
font-family: var(--mono);
font-size: 0.68rem;
color: var(--text-dim);
opacity: 0.75;
cursor: default;
user-select: none;
}
.disk-warning {
display: flex;
align-items: center;

View File

@ -13,8 +13,26 @@
--success: #6baa6b;
--warning: #c8a03a;
--error: #c85a3a;
--shadow-ring: rgba(255,255,255,0.1);
--radius: 6px;
--sidebar: 220px;
--mono: 'DM Mono', monospace;
--serif: 'Libre Baskerville', Georgia, serif;
}
/* ── Sepia reading theme (reader only) ──────────────────────────────────── */
:root[data-theme="sepia"] {
--bg: #f4ecd8;
--surface: #ece2cb;
--surface2: #e3d7bb;
--border: #d8cbac;
--accent: #c17d12;
--accent2: #a9690c;
--text: #5b4636;
--text-dim: #7c6a55;
--text-faint: #ab9a82;
--success: #4e8a4e;
--warning: #9a7714;
--error: #b04a2c;
--shadow-ring: rgba(0,0,0,0.12);
}

View File

@ -289,6 +289,7 @@
</svg>
<span id="rescan-label">Rescan library</span>
</button>
<span class="sidebar-version" title="Running Novela build">{{ app_version() }}</span>
</div>
</aside>

View File

@ -242,7 +242,10 @@
<section class="card">
<div class="card-head">Restore</div>
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
Browse a snapshot and restore individual books from Dropbox back to disk.
Browse a snapshot and restore individual books from Dropbox. File books are
written back to disk; database books (format <strong>DB</strong>) are re-inserted
into the library. Database books only appear in snapshots created after this
feature was added — to recover older database books, use Full Database Restore below.
</p>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;">
<select class="field-input" id="snapshot-select" style="flex:1;min-width:220px;margin:0;" onchange="onSnapshotChange()">
@ -275,6 +278,54 @@
</div>
<div class="status-line" id="restore-status"></div>
</section>
<section class="card">
<div class="card-head">Full Database Restore</div>
<p class="muted" style="margin-top:0;margin-bottom:0.6rem;">
Restore the entire PostgreSQL database from a Dropbox <code>pg_dump</code>. This
recovers <strong>everything</strong> — including all database-stored books, reading
progress, tags and settings — from any backup.
</p>
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;color:var(--err);">
⚠ Destructive: the current database is dropped and replaced entirely. There is no undo.
</p>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;">
<select class="field-input" id="pgdump-select" style="flex:1;min-width:220px;margin:0;">
<option value="">— select database dump —</option>
</select>
<button class="btn" onclick="loadPgDumps()">Refresh</button>
<button class="btn primary" id="btn-pg-restore" onclick="restorePgDump()" disabled>Restore database</button>
</div>
<div class="status-line" id="pgdump-status"></div>
</section>
<section class="card">
<div class="card-head">Local Database Restore (no Dropbox needed)</div>
<p class="muted" style="margin-top:0;margin-bottom:0.6rem;">
Before every restore a safety snapshot of the current database is saved locally
on the config volume (named <code>pre-restore-…</code>), so you can roll back or
restore without a Dropbox token. You can also upload a <code>.sql</code> dump you
downloaded from Dropbox manually. Regular backups remain Dropbox-only.
</p>
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;color:var(--err);">
⚠ Destructive: replaces the entire current database (with automatic rollback if the dump fails to load).
</p>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;">
<select class="field-input" id="localdump-select" style="flex:1;min-width:220px;margin:0;">
<option value="">— select local dump —</option>
</select>
<button class="btn" onclick="loadLocalDumps()">Refresh</button>
<button class="btn primary" id="btn-local-restore" onclick="restoreLocalDump()" disabled>Restore from local</button>
</div>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-top:0.4rem;border-top:1px solid var(--border);padding-top:0.8rem;">
<input class="field-input" id="upload-dump-file" type="file" accept=".sql" style="flex:1;min-width:220px;margin:0;"/>
<button class="btn primary" id="btn-upload-restore" onclick="uploadRestoreDump()">Upload &amp; restore</button>
</div>
<div class="status-line" id="localdump-status"></div>
</section>
</main>
<script src="/static/books.js"></script>
@ -302,6 +353,7 @@
rowHtml('Snapshots keep', d.retention_count ?? 14),
rowHtml('Schedule', d.schedule_enabled ? `enabled (${d.schedule_interval_hours || 24}h)` : 'disabled'),
rowHtml('pg_dump', d.pg_dump_available ? (d.pg_dump_path || 'available') : 'missing'),
rowHtml('psql', d.psql_available ? (d.psql_path || 'available') : 'missing'),
rowHtml('Library exists', fmtStatus(d.library_exists)),
rowHtml('Library path', d.library_path || '-'),
].join('');
@ -545,7 +597,7 @@
}
async function refreshAll() {
await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots()]);
await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots(), loadPgDumps(), loadLocalDumps()]);
pollRunProgress();
}
@ -648,13 +700,18 @@
return;
}
body.innerHTML = filtered.map(f => {
const ext = f.path.split('.').pop().toUpperCase();
const isDb = f.storage === 'db';
const ext = isDb ? 'DB' : f.path.split('.').pop().toUpperCase();
const parts = f.path.split('/');
const name = parts[parts.length - 1];
const dir = parts.slice(0, -1).join('/');
const onDisk = f.exists_locally
const onDisk = isDb
? (f.exists_locally
? '<span class="ok" title="Book already present in the library">&#10003; in library</span>'
: '<span class="warn">not in library</span>')
: (f.exists_locally
? '<span class="ok" title="File already on disk">&#10003; exists</span>'
: '<span class="warn">missing</span>';
: '<span class="warn">missing</span>');
return `<tr>
<td><input type="checkbox" class="restore-chk" data-path="${esc(f.path)}" onchange="updateRestoreBtn()"/></td>
<td><span style="font-family:var(--mono);font-size:0.68rem;color:var(--text-dim)">${esc(ext)}</span></td>
@ -722,7 +779,213 @@
}
}
// ── Full database restore ────────────────────────────────────────────────
async function loadPgDumps() {
const sel = document.getElementById('pgdump-select');
const btn = document.getElementById('btn-pg-restore');
try {
const r = await fetch('/api/backup/postgres/dumps');
const d = await r.json();
if (!d.ok || !d.dumps.length) {
sel.innerHTML = '<option value="">— no database dumps available —</option>';
btn.disabled = true;
return;
}
const current = sel.value;
sel.innerHTML = '<option value="">— select database dump —</option>' +
d.dumps.map(x => `<option value="${esc(x.name)}"${x.name === current ? ' selected' : ''}>${esc(x.name)}</option>`).join('');
btn.disabled = !sel.value;
} catch (_) {
sel.innerHTML = '<option value="">— Dropbox not configured —</option>';
btn.disabled = true;
}
}
document.getElementById('pgdump-select').addEventListener('change', (e) => {
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');
if (!name) return;
if (!confirm(`Restore the ENTIRE database from "${name}"?\n\nThis drops and replaces the current database. All current data will be lost. This cannot be undone.`)) return;
if (!confirm('Are you absolutely sure? This is your last chance to cancel.')) return;
const btn = document.getElementById('btn-pg-restore');
btn.disabled = true;
out.className = 'status-line warn';
out.textContent = 'Starting restore…';
try {
const r = await fetch('/api/backup/postgres/restore', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name}),
});
const d = await r.json();
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}`;
btn.disabled = false;
}
}
// ── Local / upload database restore ──────────────────────────────────────
async function loadLocalDumps() {
const sel = document.getElementById('localdump-select');
const btn = document.getElementById('btn-local-restore');
try {
const r = await fetch('/api/backup/local/dumps');
const d = await r.json();
if (!d.ok || !d.dumps.length) {
sel.innerHTML = '<option value="">— no local dumps yet —</option>';
btn.disabled = true;
return;
}
const current = sel.value;
sel.innerHTML = '<option value="">— select local dump —</option>' +
d.dumps.map(x => `<option value="${esc(x.name)}"${x.name === current ? ' selected' : ''}>${esc(x.name)} (${fmtBytes(x.size_bytes)})</option>`).join('');
btn.disabled = !sel.value;
} catch (_) {
sel.innerHTML = '<option value="">— unavailable —</option>';
btn.disabled = true;
}
}
document.getElementById('localdump-select').addEventListener('change', (e) => {
document.getElementById('btn-local-restore').disabled = !e.target.value;
});
async function restoreLocalDump() {
const name = document.getElementById('localdump-select').value;
const out = document.getElementById('localdump-status');
if (!name) return;
if (!confirm(`Restore the ENTIRE database from local dump "${name}"?\n\nThis replaces the current database. A safety snapshot is taken first and rolled back automatically if the dump fails to load.`)) return;
const btn = document.getElementById('btn-local-restore');
btn.disabled = true;
out.className = 'status-line warn';
out.textContent = 'Starting restore…';
try {
const r = await fetch('/api/backup/local/restore', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name}),
});
const d = await r.json();
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}`;
btn.disabled = false;
}
}
async function uploadRestoreDump() {
const input = document.getElementById('upload-dump-file');
const out = document.getElementById('localdump-status');
const f = input.files && input.files[0];
if (!f) {
out.className = 'status-line err';
out.textContent = 'Choose a .sql dump file to upload first.';
return;
}
if (!confirm(`Restore the ENTIRE database from uploaded file "${f.name}"?\n\nThis replaces the current database. A safety snapshot is taken first and rolled back automatically if the dump fails to load.`)) return;
const btn = document.getElementById('btn-upload-restore');
btn.disabled = true;
out.className = 'status-line warn';
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 || !d.started) throw new Error(d.error || 'failed to start');
input.value = '';
await pollRestoreProgress(out, [btn]);
} catch (e) {
out.className = 'status-line err';
out.textContent = `Upload restore failed: ${e}`;
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

@ -82,7 +82,7 @@
<!-- Find & Replace modal -->
<div class="modal-backdrop" id="replace-modal">
<div class="modal">
<div class="modal-title">Find &amp; Replace — all chapters</div>
<div class="modal-title">Find &amp; Replace</div>
<div class="modal-field">
<label class="modal-label">Search</label>
<input class="modal-input" id="rp-search" type="text" placeholder="Search…" autocomplete="off"/>
@ -94,6 +94,7 @@
<div class="modal-options">
<label class="modal-opt"><input type="checkbox" id="rp-regex"/> Regex</label>
<label class="modal-opt"><input type="checkbox" id="rp-case"/> Case sensitive</label>
<label class="modal-opt"><input type="checkbox" id="rp-current"/> Current chapter only</label>
</div>
<div class="modal-progress" id="rp-progress"></div>
<div class="modal-actions">

View File

@ -71,7 +71,7 @@
.btn-header-bm:hover { background: rgba(255,162,14,0.08); border-color: var(--accent); }
.btn-header-series {
display: none;
color: var(--text-faint); border-color: rgba(255,255,255,0.08);
color: var(--text-faint); border-color: var(--border);
padding: 0.3rem 0.5rem;
}
.btn-header-series.active {
@ -177,11 +177,26 @@
width: 24px; height: 24px; border-radius: 50%;
border: 2px solid transparent;
cursor: pointer; transition: border-color 0.12s, transform 0.1s;
box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
box-shadow: 0 0 0 1px var(--shadow-ring);
padding: 0;
}
.colour-swatch:hover { transform: scale(1.15); }
.colour-swatch.active { border-color: var(--accent); }
.colour-swatches.hidden { display: none; }
/* ── Theme toggle ── */
.theme-options { display: flex; gap: 0.5rem; }
.theme-btn {
flex: 1;
padding: 0.4rem 0.6rem;
background: var(--surface2); border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--mono); font-size: 0.7rem;
color: var(--text-dim); cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.theme-btn:hover { color: var(--text); border-color: var(--text-faint); }
.theme-btn.active { color: var(--accent); border-color: var(--accent); }
/* ── Viewer ── */
#viewer {
@ -219,8 +234,10 @@
margin-bottom: 2.5rem; padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
#chapter-content .subheading { color: rgb(224, 62, 45); font-weight: bold; }
#chapter-content .chat { color: rgb(230, 126, 35); }
#chapter-content .subheading,
#chapter-content .subheading * { color: rgb(224, 62, 45); font-weight: bold; }
#chapter-content .chat,
#chapter-content .chat * { color: rgb(230, 126, 35); }
#chapter-content p[style*="padding-left"] { padding-left: 40px; }
#chapter-content .novela-comment {
border-left: 3px solid #6b9dd6;
@ -298,6 +315,16 @@
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<script>
/* Apply saved reading theme before paint to avoid a flash. */
(function () {
try {
if (localStorage.getItem('reader-theme') === 'sepia') {
document.documentElement.setAttribute('data-theme', 'sepia');
}
} catch (e) {}
})();
</script>
</head>
<body>
@ -325,6 +352,13 @@
<div class="settings-overlay" id="settings-overlay" onclick="closeSettings()"></div>
<div class="settings-drawer" id="settings-drawer">
<div class="settings-drawer-title">Reading settings</div>
<div class="settings-row">
<div class="settings-label">Theme</div>
<div class="theme-options">
<button class="theme-btn" data-theme="dark" onclick="applyTheme('dark')">Dark</button>
<button class="theme-btn" data-theme="sepia" onclick="applyTheme('sepia')">Sepia</button>
</div>
</div>
<div class="settings-row">
<div class="settings-label">
Content width
@ -343,13 +377,20 @@
</div>
<div class="settings-row">
<div class="settings-label">Text colour</div>
<div class="colour-swatches">
<div class="colour-swatches" data-theme="dark">
<button class="colour-swatch" data-colour="#e8e2d9" title="Bright" style="background:#e8e2d9" onclick="applyTextColour('#e8e2d9')"></button>
<button class="colour-swatch" data-colour="#d4cec5" title="Warm cream" style="background:#d4cec5" onclick="applyTextColour('#d4cec5')"></button>
<button class="colour-swatch" data-colour="#bfb8ae" title="Soft sand" style="background:#bfb8ae" onclick="applyTextColour('#bfb8ae')"></button>
<button class="colour-swatch" data-colour="#a9a29a" title="Muted" style="background:#a9a29a" onclick="applyTextColour('#a9a29a')"></button>
<button class="colour-swatch" data-colour="#938d86" title="Dim" style="background:#938d86" onclick="applyTextColour('#938d86')"></button>
</div>
<div class="colour-swatches hidden" data-theme="sepia">
<button class="colour-swatch" data-colour="#5b4636" title="Warm brown" style="background:#5b4636" onclick="applyTextColour('#5b4636')"></button>
<button class="colour-swatch" data-colour="#4a3a2c" title="Dark cocoa" style="background:#4a3a2c" onclick="applyTextColour('#4a3a2c')"></button>
<button class="colour-swatch" data-colour="#6b5746" title="Medium" style="background:#6b5746" onclick="applyTextColour('#6b5746')"></button>
<button class="colour-swatch" data-colour="#3a2e24" title="Near black" style="background:#3a2e24" onclick="applyTextColour('#3a2e24')"></button>
<button class="colour-swatch" data-colour="#7c6a55" title="Dim" style="background:#7c6a55" onclick="applyTextColour('#7c6a55')"></button>
</div>
</div>
</div>
@ -450,18 +491,49 @@
applyWidth(saved);
}
// ── Text colour ────────────────────────────────────────────────
function applyTextColour(hex) {
document.documentElement.style.setProperty('--text', hex);
localStorage.setItem('reader-text-colour', hex);
document.querySelectorAll('.colour-swatch').forEach(el => {
el.classList.toggle('active', el.dataset.colour === hex);
// ── Theme (Dark / Sepia) ───────────────────────────────────────
const THEME_TEXT_DEFAULT = { dark: '#e8e2d9', sepia: '#5b4636' };
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'sepia' ? 'sepia' : 'dark';
}
function applyTheme(theme) {
if (theme !== 'sepia') theme = 'dark';
if (theme === 'dark') {
document.documentElement.removeAttribute('data-theme');
} else {
document.documentElement.setAttribute('data-theme', theme);
}
localStorage.setItem('reader-theme', theme);
document.querySelectorAll('.theme-btn').forEach(el => {
el.classList.toggle('active', el.dataset.theme === theme);
});
document.querySelectorAll('.colour-swatches').forEach(el => {
el.classList.toggle('hidden', el.dataset.theme !== theme);
});
loadTextColour();
}
function loadTheme() {
applyTheme(localStorage.getItem('reader-theme') || 'dark');
}
// ── Text colour (per theme) ────────────────────────────────────
function applyTextColour(hex) {
const theme = currentTheme();
document.documentElement.style.setProperty('--text', hex);
localStorage.setItem('reader-text-colour-' + theme, hex);
document.querySelectorAll('.colour-swatches[data-theme="' + theme + '"] .colour-swatch')
.forEach(el => el.classList.toggle('active', el.dataset.colour === hex));
}
function loadTextColour() {
const saved = localStorage.getItem('reader-text-colour') || '#e8e2d9';
applyTextColour(saved);
const theme = currentTheme();
let saved = localStorage.getItem('reader-text-colour-' + theme);
// Migrate the pre-theme single key into the dark slot.
if (!saved && theme === 'dark') saved = localStorage.getItem('reader-text-colour');
applyTextColour(saved || THEME_TEXT_DEFAULT[theme]);
}
// ── Font size ──────────────────────────────────────────────────
@ -629,15 +701,16 @@
// ── 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(); }
});
// ── Init ───────────────────────────────────────────────────────
async function init() {
loadWidth();
loadTextColour();
loadTheme();
loadFontSize();
loadSeriesNav();

View File

@ -0,0 +1,26 @@
"""Novela version metadata.
The release version is the single source maintained in ``changelog.py``
(``CHANGELOG[0]["version"]``). Dev/test builds append an explicit ``BUILD``
segment that is incremented by ``scripts/bump-dev-build.py`` on every test
build, so operators can see exactly which image build is running in the
sidebar. ``BUILD`` is reset to 0 for releases.
"""
from __future__ import annotations
from changelog import CHANGELOG
BUILD = 0
def _release_version() -> str:
"""Return the semantic release version (e.g. v0.2.11)."""
return CHANGELOG[0]["version"] if CHANGELOG else "v0.0.0"
def display_version() -> str:
"""Return the user-visible Novela version (e.g. v0.2.11 or v0.2.11.3)."""
version = _release_version()
if BUILD > 0:
return f"{version}.{BUILD}"
return version

View File

@ -1,5 +1,108 @@
# 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
- The build version at the bottom of the sidebar is no longer a clickable link to the changelog — it is now a plain text display. It exists only to show that the running build number has incremented (so it's clear the new code is active).
- `templates/_sidebar.html`: `<a href="/changelog" class="sidebar-version">``<span class="sidebar-version">`.
- `static/sidebar.css`: dropped the link/hover styling; added `cursor: default` and `user-select: none`.
## 2026-06-01 — Local pre-restore snapshots + token-free / upload restore
### Added
- Every restore now writes its **pre-restore safety dump to a local store** on the config volume (`CONFIG_DIR/postgres_dumps/`, named `pre-restore-…`, with snapshot-equal retention), so the database can be restored or rolled back **without a Dropbox token**. Regular backups are intentionally left **Dropbox-only** — production backs up hourly, so writing every backup to disk would fill the volume.
- `routers/backup.py`: `LOCAL_DUMP_DIR`, helpers `_save_local_dump`, `_list_local_dumps`, `_enforce_local_dump_retention`, `_resolve_local_dump`; `_run_pg_restore` persists the safety dump locally (and prunes by retention) before the destructive load. `_run_backup_internal` is unchanged (no local copy).
- New **Local Database Restore** card on the Backup page with two token-free paths:
- Restore from a locally stored dump — `GET /api/backup/local/dumps`, `POST /api/backup/local/restore`.
- Upload a `.sql` dump (e.g. downloaded manually from dropbox.com) and restore it — `POST /api/backup/upload-restore`. The upload itself is not persisted; the automatic pre-restore safety snapshot covers the local copy.
- Both reuse the safe restore path (pre-restore safety dump + automatic rollback) and require `psql`.
- `templates/backup.html`: new card with a local-dump selector, an upload field, double-guarded restore buttons, and `fmtBytes` sizes.
## 2026-06-01 — Full Database Restore: safety dump + automatic rollback
### Fixed
- Full Database Restore could leave the database **empty** if the dump failed to load: the restore dropped and recreated the public schema first and only then applied the dump, so any failure during the load (e.g. a header `transaction_timeout` error with `ON_ERROR_STOP`) wiped all data with nothing to fall back to.
- `routers/backup.py`: split the load into `_apply_pg_dump`; `_run_pg_restore` now takes a safety `pg_dump` of the current database before the destructive load and, if applying the requested dump fails, automatically rolls back to that safety snapshot. A failed restore therefore no longer leaves an empty or broken database.
## 2026-06-01 — Full Database Restore: tolerate PostgreSQL version mismatch
### Fixed
- Full Database Restore failed with `ERROR: unrecognized configuration parameter "transaction_timeout"` when the dump was produced by a newer `pg_dump` (PostgreSQL 17+, which emits `SET transaction_timeout = 0;` in the header) but restored into an older server (<17) that doesn't know that session parameter. The restore ran with `ON_ERROR_STOP=1` and aborted on that harmless header line.
- `routers/backup.py` (`_run_pg_restore`): the dump is now applied without `ON_ERROR_STOP`; stderr is inspected afterwards via `_real_restore_errors`, which ignores benign "unrecognized configuration parameter" errors but still fails the restore on any other `ERROR:` line. The schema-reset step keeps `ON_ERROR_STOP=1` (it is our own controlled SQL).
## 2026-06-01 — Backup/Restore: database-stored books are now restorable
### Fixed
- Database-stored books (`storage_type='db'`, synthetic `db/...` filenames) could not be restored through the Backup → Restore option. The restore UI only listed and restored files from the on-disk library object store, but db-books have no file on disk — their content lives entirely in PostgreSQL (`book_chapters` + `library` row + `book_tags` + `library_cover_cache`). They were captured only in the full `pg_dump`, which the UI offered no way to restore. As a result db-books never appeared in the restore list and were effectively unrecoverable per-book.
### Added
- **Per-book db restore.** The backup writer now serializes each database book to JSON (library row, all chapters, tags, and the cached cover) and stores it in the same content-addressed Dropbox object store used for file books, referenced from the snapshot with a `"storage": "db"` marker. Such books now appear in the Restore table (format **DB**) and can be restored individually; restore re-inserts the library row, chapters, tags and cover into PostgreSQL. Inline chapter images are unaffected — they already live on disk under `library/images/` and are backed up as ordinary files.
- `routers/backup.py`: new `_db_book_filenames`, `_serialize_db_book`, `_restore_db_book`, `_entry_storage` helpers; db-books serialized during `_run_backup_internal`; `_download_and_restore` branches on storage type; `/api/backup/snapshots/{name}/files` now reports `storage` and computes `exists_locally` for db-books from the `library` table.
- Note: db-books only appear in snapshots created *after* this change. Older backups still contain them only in the `pg_dump` — recover those via Full Database Restore below.
- **Full Database Restore.** New Backup-page card and endpoints to restore the entire PostgreSQL database from any Dropbox `pg_dump`. This recovers everything (all db-books, reading progress, tags, settings) from existing backups too. It is destructive: the public schema is dropped and recreated before the dump is applied, so any plain dump (with or without `--clean`) restores cleanly. Guarded behind a double confirmation in the UI.
- `routers/backup.py`: `_psql_base_args`, `_run_pg_restore`, `_list_pg_dump_paths`; endpoints `GET /api/backup/postgres/dumps` and `POST /api/backup/postgres/restore`; health endpoint now also reports `psql_available`/`psql_path`.
- `templates/backup.html`: db-books shown with **DB** format and "in library / not in library" status in the restore table; new "Full Database Restore" card with dump selector, double-confirm, and `psql` health row.
---
*Released as v0.2.13 on 2026-06-01*
## 2026-06-01 — Reader: Sepia reading theme
### Added
- The reader now offers a **Sepia** theme alongside the existing Dark theme, for easier long-form reading (warm paper background with dark brown text instead of light-on-black). New **Theme** toggle (Dark / Sepia) at the top of the reading settings drawer.
- `static/theme.css`: added a `:root[data-theme="sepia"]` palette overriding `--bg`, `--surface`, `--surface2`, `--border`, `--accent`, `--accent2`, `--text`, `--text-dim`, `--text-faint`, `--success`, `--warning`, `--error`. Also added a `--shadow-ring` variable (light hairline in dark, dark hairline in sepia) so swatch rings read correctly in both themes.
- `templates/reader.html`: theme toggle buttons + `.theme-btn` styling; an inline head script applies the saved theme before paint to avoid a flash; the Text colour row now has two theme-specific swatch sets (light tints for dark, dark brown tints for sepia), toggled by visibility.
- JS: `applyTheme()`/`loadTheme()` set the `data-theme` attribute and persist to `localStorage` (`reader-theme`). Text colour is now stored per theme (`reader-text-colour-dark` / `reader-text-colour-sepia`); the old single `reader-text-colour` key is migrated into the dark slot. `init()` calls `loadTheme()` (which loads the active theme's text colour).
- Replaced two hard-coded `rgba(255,255,255,...)` values in the reader CSS (swatch ring, series-button border) with theme variables so they don't show as white halos in sepia.
## 2026-05-31 — Build version in the sidebar
### Added
- The sidebar now shows the running build version at the bottom (e.g. `v0.2.11` for releases, `v0.2.11.3` for dev builds), linking to the changelog page.
- New `containers/novela/version.py` exposes `display_version()`. The semantic release version stays the single source in `changelog.py` (`CHANGELOG[0]["version"]`); a `BUILD` segment is appended for dev builds.
- `shared_templates.py` registers `app_version` as a Jinja global; `_sidebar.html` renders it as a `.sidebar-version` link styled in `sidebar.css`.
- `main.py` adds a `/api/version` endpoint returning `{"version": display_version()}`.
## 2026-05-31 — Find & Replace: scope option
### Added
- Editor Find & Replace: new **Current chapter only** checkbox in the modal options. When checked, the search/replace runs against the currently open chapter instead of every chapter in the book.
- `templates/editor.html`: added the `rp-current` checkbox next to Regex/Case sensitive; modal title changed from "Find & Replace — all chapters" to "Find & Replace" since scope is now selectable.
- `static/editor.js` (`replaceInAllChapters()`): reads `rp-current`; builds a `targets` list of either just `currentCh()` or all `chapters`, and iterates that. Shows an error ("No chapter open.") if Current-chapter-only is selected with no open chapter. Result message reads "… in current chapter" for the single-chapter case.
- Default is unchecked, so the existing all-chapters behaviour is preserved.
---
*Released as v0.2.12 on 2026-06-01*
## 2026-05-10
- Reader: subheading/chat styling now also wins when the wrapper contains block elements with their own color rule (e.g. `<div class="subheading"><p>…</p></div>`)
- Previously the `<p>`/`<h*>` rules in `reader.html` (`#chapter-content p { color: var(--text); }`, etc.) had the same specificity as `.subheading` and applied more directly to the inner element, so the parent's color was effectively overridden — the wrapped paragraph stayed in the default text color
- CSS now applies the rule to the wrapper *and* all descendants: `#chapter-content .subheading, #chapter-content .subheading * { … }` (and the same for `.chat`)
- Reason: bug reported after v0.2.10 — `.chat` worked because it wraps plain inline text inside a `<p>`, but `.subheading` wrapping a `<p>` lost its color
---
*Released as v0.2.11 on 2026-05-10*
---
*Released as v0.2.10 on 2026-05-10*
## 2026-05-09 (2)

View File

@ -1,5 +1,47 @@
# 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
- Backup: database-stored books (those kept inside the database rather than as files on disk) are now included in snapshots and can be restored one by one from the Restore screen, just like file books. They previously existed only inside the full database dump and could not be restored individually. Note: they appear in snapshots created after this update; older database books are recovered via the full database restore below.
- Backup: new **Full Database Restore** — restore the entire database from any Dropbox database dump. This recovers everything, including all database-stored books, reading progress, tags and settings, and is guarded behind a double confirmation.
- Backup: **token-free restore**. Every restore now keeps a local pre-restore safety copy of the database on the config volume, so you can restore or roll back without a Dropbox token. You can also upload a `.sql` database dump (e.g. one downloaded manually from Dropbox) and restore it directly. Regular scheduled backups stay Dropbox-only.
### Improvements
- Backup: restores now take a safety snapshot of the current database first and automatically roll back to it if the dump fails to load, so a failed restore can no longer leave the database empty or broken.
- Backup: a full restore tolerates PostgreSQL version differences (such as a newer dump's `transaction_timeout` setting) instead of aborting on them, while still failing on genuine errors.
- Sidebar: the build version indicator at the bottom is now a plain display instead of a clickable link — it exists only to show that the running build has been updated.
---
## v0.2.12 — 2026-06-01
### New features
- Reader: new **Sepia** theme alongside Dark, for easier long-form reading — a warm paper background with dark brown text instead of light-on-black. A **Theme** toggle (Dark / Sepia) sits at the top of the reading settings drawer; the choice is saved per device. Text colour is now stored per theme, so each theme keeps its own tint, and the saved theme is applied before paint to avoid a flash.
- Sidebar: the running build version is now shown at the bottom of the sidebar (e.g. `v0.2.12` for releases, `v0.2.12.3` for dev builds) and links to the changelog page.
- Editor: Find & Replace gained a **Current chapter only** option. When checked, search/replace runs against the open chapter instead of every chapter in the book; it stays unchecked by default, so the existing all-chapters behaviour is unchanged.
---
## v0.2.11 — 2026-05-10
### Bug fixes
- Reader: subheading styling now also applies when the wrapper contains a block element with its own color rule, e.g. `<div class="subheading"><p>…</p></div>`. Previously the `<p>` (and `<h*>`) rules in `reader.html` had the same CSS specificity as `.subheading` but applied more directly to the inner element, so the wrapper's color was effectively overridden and the paragraph kept the default text color. The rule now applies to the wrapper and all descendants (`#chapter-content .subheading, #chapter-content .subheading * { … }`); the same change was made to `.chat` for safety. Reported right after v0.2.10 — `.chat` was unaffected because it wraps plain inline text, but `.subheading` wrapping a paragraph lost its color.
---
## v0.2.10 — 2026-05-10
### Bug fixes

20
scripts/bump-dev-build.py Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Increment Novela's explicit dev/test build number."""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VERSION_FILE = ROOT / "containers" / "novela" / "version.py"
text = VERSION_FILE.read_text()
match = re.search(r"^BUILD = (\d+)\s*$", text, flags=re.MULTILINE)
if not match:
raise SystemExit(f"BUILD assignment not found in {VERSION_FILE}")
next_build = int(match.group(1)) + 1
text = text[: match.start(1)] + str(next_build) + text[match.end(1) :]
VERSION_FILE.write_text(text)
print(f"[bump-dev-build] BUILD = {next_build}")

View File

@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Validate Novela release version state before a release build.
Releases must ship with BUILD = 0 so the sidebar shows a clean semantic
version (e.g. v0.2.11) instead of a dev build (e.g. v0.2.11.3).
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VERSION_FILE = ROOT / "containers" / "novela" / "version.py"
text = VERSION_FILE.read_text()
match = re.search(r"^BUILD = (\d+)\s*$", text, flags=re.MULTILINE)
if not match:
raise SystemExit(f"BUILD assignment not found in {VERSION_FILE}")
build = int(match.group(1))
if build != 0:
raise SystemExit(
f"Release builds require BUILD = 0 in {VERSION_FILE}; found BUILD = {build}. "
f"Reset it before releasing."
)
print("[check-release-version] BUILD = 0")

View File

@ -1 +0,0 @@
v0.2.8