novela/containers/novela/migrations.py
Ivo Oskamp b43366723c Add Bulk Import, Following, Incomplete, status overhaul, performance, and CBR fixes
- Bulk Import page: filename pattern parsing, shared metadata, duplicate detection (volume-aware), batch upload with progress
- Following page: track external author URLs; authors table; sidebar counter
- Incomplete view: non-archived books with publication_status ≠ Complete
- Status: added Temporary Hold, renamed Hiatus → Long-Term Hold; statusBadgeHtml() helper
- Status/want-to-read badges: dark fill + ring for readability on any cover colour
- Disk usage warning in sidebar (amber/red thresholds)
- Bulk delete batched via POST /library/bulk-delete
- CBR: magic bytes format detection + py7zr 7-zip support; unrar → proprietary unrar v6
- Performance: IntersectionObserver lazy covers, ETag 304, single DOM pass, json_agg tags
- Duplicate detection in library and Convert page warning
- All books Grid/List toggle; star ratings; reader text colour presets; bookmarks
- Docs: TECHNICAL.md and changelog updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:20:25 +02:00

317 lines
9.6 KiB
Python

import re
from db import direct_connect
_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) -> None:
conn = direct_connect()
try:
with conn:
with conn.cursor() as cur:
cur.execute(sql)
finally:
conn.close()
def migrate_create_library() -> 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()
)
"""
)
def migrate_create_book_tags() -> 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)
)
"""
)
_exec("CREATE INDEX IF NOT EXISTS idx_book_tags_filename ON book_tags (filename)")
def migrate_create_reading_progress() -> 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()
)
"""
)
def migrate_create_reading_sessions() -> 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()
)
"""
)
_exec("CREATE INDEX IF NOT EXISTS idx_reading_sessions_filename ON reading_sessions (filename)")
def migrate_create_library_cover_cache() -> 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()
)
"""
)
def migrate_create_credentials() -> 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()
)
"""
)
_exec("ALTER TABLE credentials ALTER COLUMN username TYPE TEXT")
_exec("ALTER TABLE credentials ALTER COLUMN password TYPE TEXT")
def migrate_create_break_patterns() -> 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)
)
"""
)
def migrate_seed_break_patterns() -> None:
conn = direct_connect()
try:
with conn:
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,),
)
finally:
conn.close()
def migrate_create_backup_log() -> 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
)
"""
)
def migrate_add_rating() -> None:
_exec("ALTER TABLE library ADD COLUMN IF NOT EXISTS rating SMALLINT NOT NULL DEFAULT 0")
def migrate_create_bookmarks() -> 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()
)
"""
)
_exec("CREATE INDEX IF NOT EXISTS idx_bookmarks_filename ON bookmarks (filename)")
def migrate_remove_cover_missing_tag() -> None:
_exec("DELETE FROM book_tags WHERE tag = 'Cover Missing' AND tag_type = 'tag'")
def migrate_create_perf_indexes() -> None:
# Match library list sorting and common filters.
_exec(
"""
CREATE INDEX IF NOT EXISTS idx_library_sort_coalesce
ON library (
(COALESCE(publisher, '')),
(COALESCE(author, '')),
(COALESCE(series, '')),
series_index,
(COALESCE(title, ''))
)
"""
)
_exec("CREATE INDEX IF NOT EXISTS idx_library_needs_review ON library (needs_review)")
_exec("CREATE INDEX IF NOT EXISTS idx_library_archived ON library (archived)")
# Speeds grouped reads + recent-read lookups.
_exec(
"""
CREATE INDEX IF NOT EXISTS idx_reading_sessions_filename_readat
ON reading_sessions (filename, read_at DESC)
"""
)
# Helps ORDER BY filename, tag fetch for tag-map construction.
_exec(
"""
CREATE INDEX IF NOT EXISTS idx_book_tags_filename_tag
ON book_tags (filename, tag)
"""
)
def migrate_series_suffix() -> None:
_exec(
"""
ALTER TABLE library
ADD COLUMN IF NOT EXISTS series_suffix VARCHAR(10) NOT NULL DEFAULT ''
"""
)
def migrate_create_builder_drafts() -> 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()
)
"""
)
def migrate_create_authors() -> 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()
)
"""
)
def migrate_rename_hiatus() -> None:
_exec("UPDATE library SET publication_status = 'Long-Term Hold' WHERE publication_status = 'Hiatus'")
def run_migrations() -> None:
migrate_create_library()
migrate_create_book_tags()
migrate_create_reading_progress()
migrate_create_reading_sessions()
migrate_create_library_cover_cache()
migrate_create_credentials()
migrate_create_break_patterns()
migrate_create_backup_log()
migrate_create_perf_indexes()
migrate_seed_break_patterns()
migrate_add_rating()
migrate_remove_cover_missing_tag()
migrate_create_bookmarks()
migrate_series_suffix()
migrate_create_builder_drafts()
migrate_create_authors()
migrate_rename_hiatus()