novela/containers/novela/migrations.py
Ivo Oskamp 91f8380a1f Release v0.2.9
Reader: monotonic reading progress across devices — saved position only
advances, never rewinds (explicit Mark as read/unread still resets).

Plus the previously uncommitted v0.2.5–v0.2.8 work (FlareSolverr scraping,
Book Info pages, deferred chapter add/delete, scanned/uploaded backup
counters, Dropbox upload tuning, four inline editor formatting buttons,
migration logging, "New view" needs_review fix, consecutive break-image
collapsing, and the related TECHNICAL.md updates).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:50:49 +02:00

466 lines
16 KiB
Python

import logging
import re
import time
from db import direct_connect
logger = logging.getLogger(__name__)
_DEFAULT_REGEX = [
r"^\s*[\*\-]{3,}\s*$",
r"^\s*[·•◦‣⁃]\s*[·•◦‣⁃]\s*[·•◦‣⁃]\s*$",
r"^\s*~{2,}\s*$",
r"^\s*={3,}\s*$",
r"^\s*#{3,}\s*$",
r"^\s*[oO0]{1,3}\s*$",
r"^\s*[-–—]\s*[oO0]\s*[-–—]\s*$",
r"^\s*[<>]+\s*[·•*]\s*[<>]+\s*$",
]
_DEFAULT_CSS = [
"hr",
"separator",
"section-break",
"divider",
"break",
"chapterbreak",
"scene-break",
"scenebreak",
]
def _exec(sql: str, conn) -> None:
with conn.cursor() as cur:
cur.execute(sql)
def _run_once(conn, name: str, fn) -> bool:
"""Run fn(conn) only if name has not been recorded in schema_migrations.
Returns True if the migration was executed, False if it was skipped."""
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM schema_migrations WHERE name = %s", (name,))
if cur.fetchone():
logger.info("%s — skipped (already applied)", name)
return False
t0 = time.time()
fn(conn)
with conn.cursor() as cur:
cur.execute(
"INSERT INTO schema_migrations (name) VALUES (%s) ON CONFLICT DO NOTHING",
(name,),
)
conn.commit()
logger.info("%s — executed in %dms", name, int((time.time() - t0) * 1000))
return True
def migrate_create_library(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS library (
id SERIAL PRIMARY KEY,
filename VARCHAR(600) UNIQUE NOT NULL,
media_type VARCHAR(10) NOT NULL DEFAULT 'epub',
title VARCHAR(500),
author VARCHAR(255),
publisher VARCHAR(255),
series VARCHAR(500),
series_index INTEGER DEFAULT 0,
publication_status VARCHAR(100),
has_cover BOOLEAN DEFAULT FALSE,
description TEXT DEFAULT '',
source_url VARCHAR(1000),
publish_date DATE,
archived BOOLEAN DEFAULT FALSE,
want_to_read BOOLEAN DEFAULT FALSE,
needs_review BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
""",
conn,
)
def migrate_create_book_tags(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS book_tags (
id SERIAL PRIMARY KEY,
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
tag VARCHAR(255) NOT NULL,
tag_type VARCHAR(20) NOT NULL,
UNIQUE (filename, tag, tag_type)
)
""",
conn,
)
_exec("CREATE INDEX IF NOT EXISTS idx_book_tags_filename ON book_tags (filename)", conn)
def migrate_create_reading_progress(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS reading_progress (
id SERIAL PRIMARY KEY,
filename VARCHAR(600) UNIQUE NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
cfi TEXT,
page INTEGER,
progress INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT NOW()
)
""",
conn,
)
def migrate_create_reading_sessions(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS reading_sessions (
id SERIAL PRIMARY KEY,
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
read_at TIMESTAMP DEFAULT NOW()
)
""",
conn,
)
_exec("CREATE INDEX IF NOT EXISTS idx_reading_sessions_filename ON reading_sessions (filename)", conn)
def migrate_create_library_cover_cache(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS library_cover_cache (
filename VARCHAR(600) PRIMARY KEY REFERENCES library(filename) ON DELETE CASCADE,
mime_type VARCHAR(100) NOT NULL,
thumb_webp BYTEA NOT NULL,
updated_at TIMESTAMP DEFAULT NOW()
)
""",
conn,
)
def migrate_create_credentials(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS credentials (
id SERIAL PRIMARY KEY,
site VARCHAR(255) UNIQUE NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT NOW()
)
""",
conn,
)
_exec("ALTER TABLE credentials ALTER COLUMN username TYPE TEXT", conn)
_exec("ALTER TABLE credentials ALTER COLUMN password TYPE TEXT", conn)
def migrate_create_break_patterns(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS break_patterns (
id SERIAL PRIMARY KEY,
pattern_type VARCHAR(20) NOT NULL,
pattern TEXT NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (pattern_type, pattern)
)
""",
conn,
)
def migrate_seed_break_patterns(conn) -> None:
with conn.cursor() as cur:
for pat in _DEFAULT_REGEX:
re.compile(pat)
cur.execute(
"""
INSERT INTO break_patterns (pattern_type, pattern, is_default)
VALUES ('regex', %s, TRUE)
ON CONFLICT (pattern_type, pattern) DO NOTHING
""",
(pat,),
)
for pat in _DEFAULT_CSS:
cur.execute(
"""
INSERT INTO break_patterns (pattern_type, pattern, is_default)
VALUES ('css_class', %s, TRUE)
ON CONFLICT (pattern_type, pattern) DO NOTHING
""",
(pat,),
)
def migrate_create_backup_log(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS backup_log (
id SERIAL PRIMARY KEY,
status VARCHAR(20) NOT NULL,
files_count INTEGER,
size_bytes BIGINT,
error_msg TEXT,
started_at TIMESTAMP DEFAULT NOW(),
finished_at TIMESTAMP
)
""",
conn,
)
def migrate_backup_log_scanned_files(conn) -> None:
_exec("ALTER TABLE backup_log ADD COLUMN IF NOT EXISTS scanned_files INTEGER", conn)
def migrate_add_rating(conn) -> None:
_exec("ALTER TABLE library ADD COLUMN IF NOT EXISTS rating SMALLINT NOT NULL DEFAULT 0", conn)
def migrate_create_bookmarks(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS bookmarks (
id SERIAL PRIMARY KEY,
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
chapter_index INTEGER NOT NULL DEFAULT 0,
scroll_frac REAL NOT NULL DEFAULT 0,
chapter_title VARCHAR(500) NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
)
""",
conn,
)
_exec("CREATE INDEX IF NOT EXISTS idx_bookmarks_filename ON bookmarks (filename)", conn)
def migrate_remove_cover_missing_tag(conn) -> None:
_exec("DELETE FROM book_tags WHERE tag = 'Cover Missing' AND tag_type = 'tag'", conn)
def migrate_create_perf_indexes(conn) -> None:
_exec(
"""
CREATE INDEX IF NOT EXISTS idx_library_sort_coalesce
ON library (
(COALESCE(publisher, '')),
(COALESCE(author, '')),
(COALESCE(series, '')),
series_index,
(COALESCE(title, ''))
)
""",
conn,
)
_exec("CREATE INDEX IF NOT EXISTS idx_library_needs_review ON library (needs_review)", conn)
_exec("CREATE INDEX IF NOT EXISTS idx_library_archived ON library (archived)", conn)
_exec(
"""
CREATE INDEX IF NOT EXISTS idx_reading_sessions_filename_readat
ON reading_sessions (filename, read_at DESC)
""",
conn,
)
_exec(
"""
CREATE INDEX IF NOT EXISTS idx_book_tags_filename_tag
ON book_tags (filename, tag)
""",
conn,
)
def migrate_series_suffix(conn) -> None:
_exec(
"""
ALTER TABLE library
ADD COLUMN IF NOT EXISTS series_suffix VARCHAR(10) NOT NULL DEFAULT ''
""",
conn,
)
def migrate_create_builder_drafts(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS builder_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
author VARCHAR(255) NOT NULL,
publisher VARCHAR(255) NOT NULL DEFAULT '',
source_url VARCHAR(1000) NOT NULL DEFAULT '',
chapters JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
""",
conn,
)
def migrate_create_authors(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS authors (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
url VARCHAR(1000),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
""",
conn,
)
def migrate_rename_hiatus(conn) -> None:
_exec("UPDATE library SET publication_status = 'Long-Term Hold' WHERE publication_status = 'Hiatus'", conn)
def migrate_add_storage_type(conn) -> None:
_exec(
"ALTER TABLE library ADD COLUMN IF NOT EXISTS storage_type VARCHAR(10) NOT NULL DEFAULT 'file'",
conn,
)
def migrate_create_book_images(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS book_images (
sha256 CHAR(64) PRIMARY KEY,
ext VARCHAR(10) NOT NULL,
media_type VARCHAR(100) NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0
)
""",
conn,
)
def migrate_create_book_chapters(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS book_chapters (
id SERIAL PRIMARY KEY,
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
chapter_index INTEGER NOT NULL,
title VARCHAR(500) NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
content_tsv TSVECTOR,
UNIQUE (filename, chapter_index)
)
""",
conn,
)
_exec(
"CREATE INDEX IF NOT EXISTS idx_book_chapters_filename ON book_chapters (filename, chapter_index)",
conn,
)
_exec(
"CREATE INDEX IF NOT EXISTS idx_book_chapters_tsv ON book_chapters USING GIN (content_tsv)",
conn,
)
def migrate_rebuild_chapter_tsv_with_title(conn) -> None:
"""Rebuild content_tsv to include chapter title. Runs once via schema_migrations tracking."""
_exec(
"""
UPDATE book_chapters
SET content_tsv = to_tsvector('simple',
COALESCE(title, '') || ' ' ||
regexp_replace(COALESCE(content, ''), '<[^>]*>', ' ', 'g'))
""",
conn,
)
def migrate_create_app_settings(conn) -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
develop_mode BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT single_row CHECK (id = 1)
)
""",
conn,
)
_exec("INSERT INTO app_settings (id, develop_mode) VALUES (1, FALSE) ON CONFLICT DO NOTHING", conn)
def migrate_app_settings_break_image(conn) -> None:
_exec("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS break_image_sha256 VARCHAR(64) DEFAULT NULL", conn)
_exec("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS break_image_ext VARCHAR(10) DEFAULT NULL", conn)
def migrate_series_volume(conn) -> None:
_exec(
"""
ALTER TABLE library
ADD COLUMN IF NOT EXISTS series_volume VARCHAR(20) NOT NULL DEFAULT ''
""",
conn,
)
def run_migrations() -> None:
t_start = time.time()
logger.info("Starting migrations...")
conn = direct_connect()
try:
# Bootstrap: create schema_migrations table (always idempotent, no tracking needed).
with conn:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
name VARCHAR(200) PRIMARY KEY,
applied_at TIMESTAMP DEFAULT NOW()
)
"""
)
executed = sum([
_run_once(conn, "create_library", migrate_create_library),
_run_once(conn, "create_book_tags", migrate_create_book_tags),
_run_once(conn, "create_reading_progress", migrate_create_reading_progress),
_run_once(conn, "create_reading_sessions", migrate_create_reading_sessions),
_run_once(conn, "create_library_cover_cache", migrate_create_library_cover_cache),
_run_once(conn, "create_credentials", migrate_create_credentials),
_run_once(conn, "create_break_patterns", migrate_create_break_patterns),
_run_once(conn, "create_backup_log", migrate_create_backup_log),
_run_once(conn, "create_perf_indexes", migrate_create_perf_indexes),
_run_once(conn, "seed_break_patterns", migrate_seed_break_patterns),
_run_once(conn, "add_rating", migrate_add_rating),
_run_once(conn, "remove_cover_missing_tag", migrate_remove_cover_missing_tag),
_run_once(conn, "create_bookmarks", migrate_create_bookmarks),
_run_once(conn, "series_suffix", migrate_series_suffix),
_run_once(conn, "create_builder_drafts", migrate_create_builder_drafts),
_run_once(conn, "create_authors", migrate_create_authors),
_run_once(conn, "rename_hiatus", migrate_rename_hiatus),
_run_once(conn, "add_storage_type", migrate_add_storage_type),
_run_once(conn, "create_book_images", migrate_create_book_images),
_run_once(conn, "create_book_chapters", migrate_create_book_chapters),
_run_once(conn, "rebuild_chapter_tsv_with_title", migrate_rebuild_chapter_tsv_with_title),
_run_once(conn, "create_app_settings", migrate_create_app_settings),
_run_once(conn, "app_settings_break_image", migrate_app_settings_break_image),
_run_once(conn, "series_volume", migrate_series_volume),
_run_once(conn, "backup_log_scanned_files", migrate_backup_log_scanned_files),
])
finally:
conn.close()
elapsed = time.time() - t_start
if executed == 0:
logger.info("Migrations complete in %.1fs — all already applied", elapsed)
else:
logger.info("Migrations complete in %.1fs — %d executed", elapsed, executed)