Three bugs fixed: 1. Schema seeded base_url with fake placeholder 'bookhoard.example.com'. Removed seed; startup now seeds from BASE_URL env var only if DB row is empty (admin changes persist across restarts). One-time UPDATE clears the placeholder in existing installs. 2. config.GetBaseURL() had a broken type assertion (local SystemConfigRow vs database.SystemConfig) that always failed, returning . Admin panel showed env var fallback instead of actual DB value. Fixed with a function-type getter that properly wraps the DB query. 3. OPDS handler read base_url only from DB with no fallback. When DB had the placeholder, all feed links pointed to an unreachable domain, breaking KOReader search/download. Added deriveBaseURL() helper that falls back to the request Host/scheme when DB value is empty. Setup gate improvements: - isSetupComplete now requires both admin user AND non-empty base_url - Setup middleware no longer exempts all /api/ routes; only allows /api/auth/register, /api/auth/login, /api/system/config before setup is complete. All other API routes get 503. - Cache invalidated when base_url is saved via admin settings Dev workflow: - New bruno/NewDevDBSetup/SetBaseUrl.yml for dev DB setup - NewDB.sh runs SetBaseUrl between RegisterUser and CreateEbookLibrary
1362 lines
58 KiB
PL/PgSQL
1362 lines
58 KiB
PL/PgSQL
-- Consolidated Bookhoard Database Schema
|
|
-- This file contains the complete current schema for the Bookhoard media library management system
|
|
|
|
-- Create extensions for full-text search and fuzzy matching
|
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
|
|
-- Create library types table
|
|
CREATE TABLE IF NOT EXISTS library_types (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name VARCHAR(50) UNIQUE NOT NULL,
|
|
description TEXT,
|
|
allowed_extensions TEXT[] NOT NULL, -- Array of allowed file extensions for this type
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
-- Insert default library types
|
|
INSERT INTO library_types (name, description, allowed_extensions) VALUES
|
|
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
|
|
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
|
|
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif'])
|
|
ON CONFLICT (name) DO NOTHING;
|
|
|
|
-- Create users table
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
email VARCHAR(255) UNIQUE NOT NULL,
|
|
username VARCHAR(255) UNIQUE NOT NULL,
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
first_name VARCHAR(255),
|
|
last_name VARCHAR(255),
|
|
role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
|
theme VARCHAR(50) DEFAULT 'tokyo-night',
|
|
max_devices INTEGER DEFAULT 10,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
timezone VARCHAR(50) DEFAULT 'UTC',
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
-- Create system_settings table
|
|
CREATE TABLE IF NOT EXISTS system_settings (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
setting_key VARCHAR(100) UNIQUE NOT NULL,
|
|
setting_value TEXT NOT NULL,
|
|
description TEXT,
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
-- Insert default system settings
|
|
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
|
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
|
|
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
|
|
('default_timezone', 'UTC', 'System default timezone')
|
|
ON CONFLICT (setting_key) DO NOTHING;
|
|
|
|
-- Create refresh_tokens table
|
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
|
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
revoked_at TIMESTAMP WITH TIME ZONE
|
|
);
|
|
|
|
-- Create libraries table
|
|
CREATE TABLE IF NOT EXISTS libraries (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
library_type_id UUID NOT NULL REFERENCES library_types(id) ON DELETE RESTRICT,
|
|
created_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
-- Create library_folders table for multiple folders per library
|
|
CREATE TABLE IF NOT EXISTS library_folders (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
folder_path VARCHAR(500) NOT NULL,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(library_id, folder_path)
|
|
);
|
|
|
|
-- Create library_visibility table for user-specific library visibility
|
|
CREATE TABLE IF NOT EXISTS library_visibility (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
is_visible BOOLEAN NOT NULL DEFAULT true,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(user_id, library_id)
|
|
);
|
|
|
|
-- Create media_items table
|
|
CREATE TABLE IF NOT EXISTS media_items (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
title VARCHAR(255) NOT NULL,
|
|
author VARCHAR(255),
|
|
isbn VARCHAR(13), -- Still relevant for ebooks
|
|
description TEXT,
|
|
file_path VARCHAR(500) NOT NULL,
|
|
file_size BIGINT,
|
|
mime_type VARCHAR(100),
|
|
cover_image_path VARCHAR(500),
|
|
series VARCHAR(255),
|
|
series_number INTEGER,
|
|
tags TEXT[],
|
|
asin VARCHAR(20), -- Still relevant for ebooks
|
|
date_published DATE,
|
|
publisher VARCHAR(255),
|
|
contributors TEXT[],
|
|
-- Enhanced fields for better metadata and functionality
|
|
language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
|
|
edition VARCHAR(100), -- Edition information
|
|
page_count INTEGER, -- Total page count
|
|
genre VARCHAR(100), -- Genre classification
|
|
copyright_year INTEGER, -- Copyright/publication year
|
|
goodreads_id VARCHAR(50), -- Goodreads identifier
|
|
openlibrary_id VARCHAR(50), -- Open Library identifier
|
|
google_books_id VARCHAR(100), -- Google Books identifier
|
|
added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
imported_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
-- Universal Sync Format Detection
|
|
format_group VARCHAR(20) NOT NULL DEFAULT 'reflowable',
|
|
format_mimetype VARCHAR(100),
|
|
is_reflowable BOOLEAN DEFAULT TRUE,
|
|
has_fixed_layout BOOLEAN DEFAULT FALSE,
|
|
total_characters BIGINT,
|
|
chapter_count INTEGER,
|
|
-- Kobo-Specific Metadata
|
|
entitlement_id VARCHAR(255) UNIQUE,
|
|
revision_number INTEGER DEFAULT 1,
|
|
kobo_content_id VARCHAR(255),
|
|
kobo_metadata JSONB,
|
|
-- Manga and comic reading direction support
|
|
-- Stores raw Manga field from ComicInfo.xml
|
|
manga_type VARCHAR(30) DEFAULT 'unknown',
|
|
reading_direction VARCHAR(20) DEFAULT 'auto',
|
|
CHECK (manga_type IN ('unknown', 'no', 'yes', 'yes_and_right_to_left')),
|
|
CHECK (reading_direction IN ('auto', 'ltr', 'rtl', 'vertical')),
|
|
|
|
-- Universal series information (applies to ALL formats: ebooks, audiobooks, comics)
|
|
series_count INTEGER, -- Total items in series (from ComicInfo Count field, or book series count)
|
|
volume INTEGER, -- Volume/omnibus number for collected editions
|
|
|
|
-- Universal publisher and classification (applies to ALL formats)
|
|
imprint VARCHAR(255), -- Publisher imprint (e.g., Vertigo, HarperCollinsEpic)
|
|
age_rating VARCHAR(20), -- Age rating: Everyone, Teen, Mature, Adult (applies to all formats)
|
|
web_url VARCHAR(500), -- URL to info page (Goodreads, ComicVine, MangaUpdates, Audible, etc.)
|
|
|
|
-- Comic-specific fields
|
|
story_arc VARCHAR(255), -- Story arc name (e.g., "The Dark Phoenix Saga", "Civil War")
|
|
is_black_and_white BOOLEAN, -- Black and white flag (mostly comics, some illustrated books)
|
|
|
|
-- Additional metadata (applies to all formats)
|
|
metadata_notes TEXT, -- Notes from metadata files (ComicInfo.xml, EPUB, PDF) - distinct from user notes
|
|
community_rating DOUBLE PRECISION, -- Pre-existing community rating from metadata (0.0-10.0) - distinct from user ratings
|
|
|
|
-- Alternate series information (JSONB for flexible schema - comic-specific)
|
|
alternate_info JSONB, -- Stores AlternateSeries, AlternateNumber, AlternateCount
|
|
-- Example: {"alternate_series": "Ultimate X-Men", "alternate_number": 1, "alternate_count": 12}
|
|
|
|
-- Scan and publication metadata (comic-specific)
|
|
scan_information TEXT, -- Scan information (scanner group, resolution, etc.)
|
|
|
|
-- Summary (distinct from description - may merge with Calibre description)
|
|
summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre)
|
|
|
|
-- Chapter metadata for reader navigation and progress tracking
|
|
-- Caches detected chapter structure to avoid re-parsing files
|
|
-- Populated by ReaderService.DetectChapters() on first read
|
|
-- JSONB structure:
|
|
-- {
|
|
-- "chapters": [
|
|
-- {"id": "chap1", "title": "Chapter 1", "start_page": 1, "page_count": 20, "level": 1},
|
|
-- {"id": "chap2", "title": "Chapter 2", "start_page": 21, "page_count": 25, "level": 1}
|
|
-- ],
|
|
-- "detected_at": "2024-01-15T10:30:00Z",
|
|
-- "detection_method": "epub-toc" | "pdf-outline" | "page-breaks"
|
|
-- }
|
|
chapter_metadata JSONB,
|
|
-- Need library_type_name for checks
|
|
library_type_name VARCHAR(50)
|
|
);
|
|
|
|
-- Add GIN indexes for fast array searches
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_tags_gin ON media_items USING GIN (tags);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING GIN (contributors);
|
|
|
|
-- Add search fields for case-insensitive, punctuation-free searching
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
|
|
|
-- Create GIN indexes for fast search field searches
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
|
|
|
|
-- Drop existing trigger if exists
|
|
DROP TRIGGER IF EXISTS set_library_type_name_on_insert ON media_items;
|
|
-- Drop existing function if exists
|
|
DROP FUNCTION IF EXISTS set_library_type_name();
|
|
|
|
-- Add trigger to populate library_type_name on insert
|
|
CREATE OR REPLACE FUNCTION set_library_type_name() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
SELECT lt.name INTO NEW.library_type_name
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE l.id = NEW.library_id;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
CREATE TRIGGER set_library_type_name_on_insert
|
|
BEFORE INSERT ON media_items
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION set_library_type_name();
|
|
|
|
-- Add GIN indexes for pg_trgm fuzzy search performance
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
|
|
ON media_items USING GIN (author gin_trgm_ops);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_title_trgm
|
|
ON media_items USING GIN (title gin_trgm_ops);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_series_trgm
|
|
ON media_items USING GIN (series gin_trgm_ops);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_genre_trgm
|
|
ON media_items USING GIN (genre gin_trgm_ops);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_language_trgm
|
|
ON media_items USING GIN (language gin_trgm_ops);
|
|
|
|
-- Create reading_progress table
|
|
CREATE TABLE IF NOT EXISTS reading_progress (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
current_page INTEGER DEFAULT 0,
|
|
total_pages INTEGER,
|
|
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(media_item_id, user_id),
|
|
-- Universal Progress Tracking
|
|
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
|
|
character_offset BIGINT,
|
|
epubcfi TEXT,
|
|
context_text TEXT,
|
|
chapter INTEGER,
|
|
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
|
viewport_x FLOAT DEFAULT 0,
|
|
viewport_y FLOAT DEFAULT 0,
|
|
zoom_level FLOAT DEFAULT 1.0,
|
|
scroll_position_x FLOAT DEFAULT 0,
|
|
scroll_position_y FLOAT DEFAULT 0,
|
|
panel_number INTEGER,
|
|
reading_mode VARCHAR(20),
|
|
-- Device Sync Metadata
|
|
last_sync_device VARCHAR(50),
|
|
last_sync_source VARCHAR(20),
|
|
last_sync_timestamp TIMESTAMP WITH TIME ZONE,
|
|
conflict_detected BOOLEAN DEFAULT FALSE,
|
|
conflict_resolved BOOLEAN DEFAULT TRUE
|
|
);
|
|
|
|
-- Create media_ratings table
|
|
CREATE TABLE IF NOT EXISTS media_ratings (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 10), -- 10-point scale for half-star precision
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(media_item_id, user_id)
|
|
);
|
|
|
|
-- Create media_notes table for user notes on media items
|
|
CREATE TABLE IF NOT EXISTS media_notes (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
content TEXT NOT NULL,
|
|
position VARCHAR(100), -- optional position (page:offset or CFI) for standalone notes
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
-- Location Enhancements
|
|
percentage_location FLOAT,
|
|
character_start INTEGER,
|
|
character_end INTEGER,
|
|
epubcfi_location TEXT,
|
|
chapter_reference INTEGER,
|
|
paragraph_reference INTEGER,
|
|
device_sync_data JSONB
|
|
);
|
|
|
|
-- Create media_highlights table for user highlights on media items
|
|
CREATE TABLE IF NOT EXISTS media_highlights (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
selection_text TEXT NOT NULL,
|
|
start_position VARCHAR(100), -- position (page:offset or CFI) where highlight starts
|
|
end_position VARCHAR(100), -- position (page:offset or CFI) where highlight ends
|
|
color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
|
|
note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
-- Location Enhancements
|
|
percentage_start FLOAT,
|
|
percentage_end FLOAT,
|
|
character_start INTEGER,
|
|
character_end INTEGER,
|
|
epubcfi_start TEXT,
|
|
epubcfi_end TEXT,
|
|
chapter_reference INTEGER,
|
|
paragraph_start INTEGER,
|
|
paragraph_end INTEGER,
|
|
panel_number INTEGER,
|
|
device_sync_data JSONB
|
|
);
|
|
|
|
-- ============================================
|
|
-- DEVICE REGISTRY
|
|
-- ============================================
|
|
CREATE TABLE IF NOT EXISTS devices (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
device_name VARCHAR(100) NOT NULL,
|
|
device_type VARCHAR(20) NOT NULL,
|
|
device_identifier VARCHAR(255) UNIQUE NOT NULL,
|
|
auth_token VARCHAR(500) UNIQUE NOT NULL,
|
|
last_sync TIMESTAMP WITH TIME ZONE,
|
|
last_seen TIMESTAMP WITH TIME ZONE,
|
|
sync_enabled BOOLEAN DEFAULT TRUE,
|
|
auto_sync BOOLEAN DEFAULT TRUE,
|
|
sync_frequency_minutes INTEGER DEFAULT 5,
|
|
device_metadata JSONB,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
-- ============================================
|
|
-- SYNC QUEUE FOR OFFLINE SUPPORT
|
|
-- ============================================
|
|
CREATE TABLE IF NOT EXISTS sync_queue (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
media_item_id UUID REFERENCES media_items(id) ON DELETE CASCADE,
|
|
sync_type VARCHAR(20) NOT NULL,
|
|
sync_data JSONB NOT NULL,
|
|
priority INTEGER DEFAULT 5,
|
|
attempts INTEGER DEFAULT 0,
|
|
max_attempts INTEGER DEFAULT 3,
|
|
status VARCHAR(20) DEFAULT 'pending',
|
|
error_message TEXT,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
processed_at TIMESTAMP WITH TIME ZONE
|
|
);
|
|
|
|
-- ============================================
|
|
-- CONFLICT RESOLUTION
|
|
-- ============================================
|
|
CREATE TABLE IF NOT EXISTS sync_conflicts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
conflict_type VARCHAR(20) NOT NULL,
|
|
conflict_data JSONB NOT NULL,
|
|
resolution_status VARCHAR(20) DEFAULT 'unresolved',
|
|
resolution_data JSONB,
|
|
resolved_by UUID REFERENCES users(id),
|
|
resolved_at TIMESTAMP WITH TIME ZONE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
-- ============================================
|
|
-- KOBO SHELF MANAGEMENT
|
|
-- ============================================
|
|
CREATE TABLE IF NOT EXISTS kobo_shelves (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
shelf_name VARCHAR(100) DEFAULT 'Default',
|
|
shelf_position INTEGER DEFAULT 0,
|
|
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
last_synced_at TIMESTAMP WITH TIME ZONE,
|
|
UNIQUE(device_id, media_item_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_kobo_shelves_device_id ON kobo_shelves(device_id);
|
|
CREATE INDEX IF NOT EXISTS idx_kobo_shelves_media_item_id ON kobo_shelves(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_kobo_shelves_shelf_name ON kobo_shelves(shelf_name);
|
|
|
|
-- Table to track items that can't be processed in their library
|
|
CREATE TABLE IF NOT EXISTS processing_issues (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
issue_type VARCHAR(50) NOT NULL,
|
|
issue_description TEXT NOT NULL,
|
|
severity VARCHAR(20) NOT NULL DEFAULT 'warning',
|
|
resolved BOOLEAN DEFAULT FALSE,
|
|
resolved_at TIMESTAMP WITH TIME ZONE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(media_item_id, issue_type)
|
|
);
|
|
|
|
-- Indexes for querying problem items
|
|
CREATE INDEX IF NOT EXISTS idx_processing_issues_library
|
|
ON processing_issues(library_id, resolved);
|
|
CREATE INDEX IF NOT EXISTS idx_processing_issues_severity
|
|
ON processing_issues(severity, resolved);
|
|
|
|
-- Add comment for documentation
|
|
COMMENT ON TABLE processing_issues IS 'Tracks media items that cannot be properly processed in their assigned library due to format mismatches or other issues';
|
|
|
|
|
|
-- ============================================
|
|
-- KOBO ENTITLEMENTS TRACKING
|
|
-- ============================================
|
|
CREATE TABLE IF NOT EXISTS kobo_entitlements (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
entitlement_id VARCHAR(255) NOT NULL,
|
|
content_id VARCHAR(255) NOT NULL,
|
|
revision_number INTEGER DEFAULT 1,
|
|
purchase_date TIMESTAMP WITH TIME ZONE,
|
|
accession_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
book_status VARCHAR(20) DEFAULT 'installed',
|
|
sync_status VARCHAR(20) DEFAULT 'synced',
|
|
kobo_metadata JSONB,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(device_id, entitlement_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_device_id ON kobo_entitlements(device_id);
|
|
CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_media_item_id ON kobo_entitlements(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_entitlement_id ON kobo_entitlements(entitlement_id);
|
|
CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_content_id ON kobo_entitlements(content_id);
|
|
|
|
-- ============================================
|
|
-- READING HISTORY
|
|
-- ============================================
|
|
CREATE TABLE IF NOT EXISTS reading_history (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
device_id UUID REFERENCES devices(id),
|
|
progress_percentage FLOAT,
|
|
reading_session_start TIMESTAMP WITH TIME ZONE,
|
|
reading_session_end TIMESTAMP WITH TIME ZONE,
|
|
pages_read INTEGER,
|
|
time_spent_seconds INTEGER,
|
|
device_metadata JSONB,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
|
|
-- Create indexes for better query performance
|
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
|
|
CREATE INDEX IF NOT EXISTS idx_library_types_name ON library_types(name);
|
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
|
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
|
|
|
-- Library indexes
|
|
CREATE INDEX IF NOT EXISTS idx_libraries_name ON libraries(name);
|
|
CREATE INDEX IF NOT EXISTS idx_libraries_library_type_id ON libraries(library_type_id);
|
|
CREATE INDEX IF NOT EXISTS idx_libraries_created_by_admin_id ON libraries(created_by_admin_id);
|
|
CREATE INDEX IF NOT EXISTS idx_library_folders_library_id ON library_folders(library_id);
|
|
CREATE INDEX IF NOT EXISTS idx_library_visibility_user_id ON library_visibility(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_library_visibility_library_id ON library_visibility(library_id);
|
|
|
|
-- Media items indexes
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_title ON media_items(title);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_author ON media_items(author);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_library_id ON media_items(library_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_added_by_admin_id ON media_items(added_by_admin_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_genre ON media_items(genre);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_language ON media_items(language);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_copyright_year ON media_items(copyright_year);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_page_count ON media_items(page_count);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_series ON media_items(series);
|
|
|
|
-- Index for filtering by reading direction (for manga/comic libraries)
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_reading_direction
|
|
ON media_items(reading_direction)
|
|
WHERE reading_direction IS NOT NULL;
|
|
|
|
-- Index for filtering by story arc (comic-specific)
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_story_arc
|
|
ON media_items(story_arc)
|
|
WHERE story_arc IS NOT NULL;
|
|
|
|
-- Index for filtering by imprint (universal - all formats)
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_imprint
|
|
ON media_items(imprint)
|
|
WHERE imprint IS NOT NULL;
|
|
|
|
-- Index for filtering by age rating (universal - all formats)
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_age_rating
|
|
ON media_items(age_rating)
|
|
WHERE age_rating IS NOT NULL;
|
|
|
|
-- Index for filtering by manga type (comic-specific)
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_manga_type
|
|
ON media_items(manga_type)
|
|
WHERE manga_type IS NOT NULL;
|
|
|
|
-- Index for filtering by series count (universal - all formats)
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_series_count
|
|
ON media_items(series_count)
|
|
WHERE series_count IS NOT NULL;
|
|
|
|
-- Index for filtering by volume (universal - all formats)
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_volume
|
|
ON media_items(volume)
|
|
WHERE volume IS NOT NULL;
|
|
|
|
-- GIN index for alternate_info JSONB queries (comic-specific)
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_alternate_info_gin
|
|
ON media_items USING GIN (alternate_info)
|
|
WHERE alternate_info IS NOT NULL;
|
|
|
|
-- Progress and ratings indexes
|
|
CREATE INDEX IF NOT EXISTS idx_reading_progress_media_item_id ON reading_progress(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_reading_progress_user_id ON reading_progress(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_ratings_media_item_id ON media_ratings(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_ratings_user_id ON media_ratings(user_id);
|
|
|
|
-- Notes and highlights indexes
|
|
CREATE INDEX IF NOT EXISTS idx_media_notes_media_item_id ON media_notes(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_notes_user_id ON media_notes(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_highlights_media_item_id ON media_highlights(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_highlights_user_id ON media_highlights(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_highlights_note_id ON media_highlights(note_id);
|
|
|
|
-- ============================================
|
|
-- NEW TABLE INDEXES
|
|
-- ============================================
|
|
|
|
-- Device registry indexes
|
|
CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_devices_device_type ON devices(device_type);
|
|
CREATE INDEX IF NOT EXISTS idx_devices_device_identifier ON devices(device_identifier);
|
|
|
|
-- Sync queue indexes
|
|
CREATE INDEX IF NOT EXISTS idx_sync_queue_device_id ON sync_queue(device_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
|
|
CREATE INDEX IF NOT EXISTS idx_sync_queue_priority ON sync_queue(priority);
|
|
|
|
-- Sync conflicts indexes
|
|
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_media_item_id ON sync_conflicts(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user_id ON sync_conflicts(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_status ON sync_conflicts(resolution_status);
|
|
|
|
-- Composite indexes for sync queue performance
|
|
CREATE INDEX IF NOT EXISTS idx_sync_queue_device_status_priority ON sync_queue(device_id, status, priority ASC, created_at ASC);
|
|
CREATE INDEX IF NOT EXISTS idx_sync_queue_status_priority_created ON sync_queue(status, priority ASC, created_at ASC);
|
|
CREATE INDEX IF NOT EXISTS idx_sync_queue_device_created_at ON sync_queue(device_id, created_at DESC);
|
|
|
|
-- Composite indexes for reading progress performance
|
|
CREATE INDEX IF NOT EXISTS idx_reading_progress_user_last_sync ON reading_progress(user_id, last_sync_timestamp DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_reading_progress_media_last_sync ON reading_progress(media_item_id, last_sync_timestamp DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_reading_progress_user_sync_source ON reading_progress(user_id, last_sync_source, last_sync_timestamp DESC);
|
|
|
|
-- Composite indexes for devices
|
|
CREATE INDEX IF NOT EXISTS idx_devices_user_sync_enabled ON devices(user_id, sync_enabled, auto_sync);
|
|
CREATE INDEX IF NOT EXISTS idx_devices_user_type ON devices(user_id, device_type);
|
|
CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen DESC) WHERE sync_enabled = true;
|
|
|
|
-- Composite indexes for annotations
|
|
CREATE INDEX IF NOT EXISTS idx_media_notes_user_media ON media_notes(user_id, media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_highlights_user_media ON media_highlights(user_id, media_item_id);
|
|
|
|
-- Reading history indexes
|
|
CREATE INDEX IF NOT EXISTS idx_reading_history_user_id ON reading_history(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_reading_history_media_item_id ON reading_history(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_reading_history_created_at ON reading_history(created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_reading_history_user_device_created ON reading_history(user_id, device_id, created_at DESC);
|
|
|
|
-- ============================================
|
|
-- TRIGGER FUNCTIONS
|
|
-- ============================================
|
|
|
|
-- Update updated_at timestamp for devices
|
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = NOW();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Trigger for devices table
|
|
DROP TRIGGER IF EXISTS update_devices_updated_at ON devices;
|
|
CREATE TRIGGER update_devices_updated_at
|
|
BEFORE UPDATE ON devices
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION update_updated_at_column();
|
|
|
|
-- Add comment explaining the rating system
|
|
COMMENT ON COLUMN media_ratings.rating IS 'Rating scale 1-10 (odd numbers = half-stars: 1,3,5,7,9 = 0.5,1.5,2.5,3.5,4.5 stars)';
|
|
|
|
-- Enhanced metadata comments
|
|
COMMENT ON COLUMN media_items.language IS 'ISO 639-1 language code (e.g., en, es, fr)';
|
|
COMMENT ON COLUMN media_items.copyright_year IS 'Copyright or publication year for filtering/sorting';
|
|
COMMENT ON COLUMN media_items.page_count IS 'Total page count for progress tracking and sorting';
|
|
COMMENT ON COLUMN media_items.genre IS 'Genre classification for filtering and discovery';
|
|
COMMENT ON COLUMN media_items.edition IS 'Edition information (e.g., "First Edition", "Revised Edition")';
|
|
COMMENT ON COLUMN media_items.goodreads_id IS 'Goodreads book identifier for integration';
|
|
COMMENT ON COLUMN media_items.openlibrary_id IS 'Open Library identifier for integration';
|
|
COMMENT ON COLUMN media_items.google_books_id IS 'Google Books identifier for integration';
|
|
|
|
-- Comic-specific fields
|
|
COMMENT ON COLUMN media_items.manga_type IS 'Raw Manga field from ComicInfo.xml: unknown, no, yes, yes_and_right_to_left';
|
|
COMMENT ON COLUMN media_items.reading_direction IS 'Computed reading direction: auto, ltr (left-to-right), rtl (right-to-left), vertical (webtoons/manhwa)';
|
|
COMMENT ON COLUMN media_items.story_arc IS 'Story arc name for grouping related issues (e.g., "The Dark Phoenix Saga", "Civil War")';
|
|
COMMENT ON COLUMN media_items.is_black_and_white IS 'Black and white comic flag from ComicInfo.xml';
|
|
COMMENT ON COLUMN media_items.alternate_info IS 'Alternate series information as JSONB: {alternate_series, alternate_number, alternate_count}';
|
|
COMMENT ON COLUMN media_items.scan_information IS 'Scan information from ComicInfo.xml (scanner group, resolution, etc.)';
|
|
|
|
-- Universal fields (apply to ebooks, audiobooks, comics)
|
|
COMMENT ON COLUMN media_items.series_count IS 'Total items in series (from ComicInfo.xml Count field, or book series count)';
|
|
COMMENT ON COLUMN media_items.volume IS 'Volume/omnibus number for collected editions';
|
|
COMMENT ON COLUMN media_items.imprint IS 'Publisher imprint/subdivision (e.g., Vertigo, HarperCollinsEpic, DC Black Label)';
|
|
COMMENT ON COLUMN media_items.age_rating IS 'Age rating from metadata: Everyone, Teen, Mature, Adult (applies to all formats)';
|
|
COMMENT ON COLUMN media_items.web_url IS 'URL to info page (Goodreads, ComicVine, MangaUpdates, Audible, etc.)';
|
|
|
|
-- Additional metadata (applies to all formats)
|
|
COMMENT ON COLUMN media_items.metadata_notes IS 'Notes from metadata files (ComicInfo.xml, EPUB, PDF) - distinct from user notes in media_notes table';
|
|
COMMENT ON COLUMN media_items.community_rating IS 'Pre-existing community rating from metadata files (scale 0.0-10.0, DOUBLE PRECISION) - distinct from user ratings in media_ratings table';
|
|
COMMENT ON COLUMN media_items.summary IS 'Summary from ComicInfo.xml (may be merged with description from Calibre)';
|
|
|
|
-- Library Type File Extensions Notes:
|
|
-- - Ebooks: .epub, .pdf, .mobi, .azw, .azw3, .txt, .rtf, .doc, .docx, .lit, .fb2, .pdb
|
|
-- - Comics: .cbz, .cbr, .cb7, .cbt, .pdf
|
|
-- - Manga: .cbz, .cbr, .png, .jpg, .jpeg, .gif, .bmp, .webp (note: manga includes image folders)
|
|
|
|
-- Role System Notes:
|
|
-- - All users default to 'user' role
|
|
-- - Admin users can: create/manage libraries and folders, scan media, modify media metadata, delete media, control library visibility
|
|
-- - Regular users can: view visible libraries, rate media, track reading progress, create notes and highlights, manage their profile
|
|
-- - Library visibility is controlled through library_visibility table - admins can hide/show libraries per user
|
|
-- - Notes and highlights support position data (page:offset or CFI format) for precise location tracking
|
|
-- - Highlights can have associated notes for detailed annotations
|
|
|
|
-- ============================================
|
|
-- PERFORMANCE OPTIMIZATION
|
|
-- ============================================
|
|
-- NOTE: ALTER SYSTEM commands are commented out for sqlc compatibility.
|
|
-- These should be run manually by DBA or via init script:
|
|
--
|
|
-- Concurrency settings for high-throughput sync operations
|
|
-- ALTER SYSTEM SET max_connections = 200;
|
|
-- ALTER SYSTEM SET shared_buffers = '256MB';
|
|
-- ALTER SYSTEM SET effective_cache_size = '1GB';
|
|
-- ALTER SYSTEM SET maintenance_work_mem = '64MB';
|
|
-- ALTER SYSTEM SET checkpoint_completion_target = 0.9;
|
|
-- ALTER SYSTEM SET wal_buffers = '16MB';
|
|
-- ALTER SYSTEM SET default_statistics_target = 100;
|
|
--
|
|
-- Enable parallel query processing for sync operations
|
|
-- ALTER SYSTEM SET max_parallel_workers_per_gather = 4;
|
|
-- ALTER SYSTEM SET max_parallel_workers = 8;
|
|
-- ALTER SYSTEM SET parallel_setup_cost = 100;
|
|
-- ALTER SYSTEM SET parallel_tuple_cost = 0.1;
|
|
--
|
|
-- Work memory for complex sync queries
|
|
-- ALTER SYSTEM SET work_mem = '16MB';
|
|
--
|
|
-- Background writer optimization for sync queue
|
|
-- ALTER SYSTEM SET bgwriter_delay = 200ms;
|
|
-- ALTER SYSTEM SET bgwriter_lru_maxpages = 200;
|
|
-- ALTER SYSTEM SET bgwriter_lru_multiplier = 2.0;
|
|
|
|
-- Vacuum and analyze schedule for sync tables (run via cron)
|
|
-- VACUUM ANALYZE sync_queue;
|
|
-- VACUUM ANALYZE reading_progress;
|
|
-- VACUUM ANALYZE sync_conflicts;
|
|
-- VACUUM ANALYZE devices;
|
|
-- REINDEX TABLE CONCURRENTLY sync_queue;
|
|
-- REINDEX TABLE CONCURRENTLY reading_progress;
|
|
|
|
-- ============================================
|
|
-- SYNC HELPER FUNCTIONS
|
|
-- ============================================
|
|
|
|
-- Detect format group based on mimetype and file path
|
|
CREATE OR REPLACE FUNCTION detect_format_group(p_mimetype VARCHAR, p_file_path VARCHAR)
|
|
RETURNS VARCHAR AS $$
|
|
BEGIN
|
|
CASE
|
|
-- Reflowable formats
|
|
WHEN p_mimetype = 'application/epub+zip' THEN
|
|
RETURN 'reflowable';
|
|
WHEN p_mimetype = 'application/x-mobipocket-ebook' THEN
|
|
RETURN 'reflowable';
|
|
WHEN p_mimetype = 'application/vnd.amazon.mobi8-ebook' THEN
|
|
RETURN 'reflowable';
|
|
WHEN p_file_path LIKE '%.epub' THEN
|
|
RETURN 'reflowable';
|
|
WHEN p_file_path LIKE '%.mobi' THEN
|
|
RETURN 'reflowable';
|
|
WHEN p_file_path LIKE '%.azw3' THEN
|
|
RETURN 'reflowable';
|
|
WHEN p_file_path LIKE '%.fb2' THEN
|
|
RETURN 'reflowable';
|
|
WHEN p_file_path LIKE '%.txt' THEN
|
|
RETURN 'reflowable';
|
|
|
|
-- Fixed layout formats
|
|
WHEN p_mimetype = 'application/pdf' THEN
|
|
RETURN 'fixed_layout';
|
|
WHEN p_file_path LIKE '%.pdf' THEN
|
|
RETURN 'fixed_layout';
|
|
WHEN p_file_path LIKE '%.djvu' THEN
|
|
RETURN 'fixed_layout';
|
|
|
|
-- Comic archive formats
|
|
WHEN p_mimetype = 'application/x-cbr' THEN
|
|
RETURN 'comic_archive';
|
|
WHEN p_mimetype = 'application/x-cbz' THEN
|
|
RETURN 'comic_archive';
|
|
WHEN p_file_path LIKE '%.cbz' THEN
|
|
RETURN 'comic_archive';
|
|
WHEN p_file_path LIKE '%.cbr' THEN
|
|
RETURN 'comic_archive';
|
|
WHEN p_file_path LIKE '%.cbt' THEN
|
|
RETURN 'comic_archive';
|
|
WHEN p_file_path LIKE '%.cb7' THEN
|
|
RETURN 'comic_archive';
|
|
|
|
ELSE
|
|
RETURN 'unknown';
|
|
END CASE;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Function to detect fixed-layout EPUBs from OPF content
|
|
CREATE OR REPLACE FUNCTION detect_fixed_layout_epub(opf_content TEXT)
|
|
RETURNS BOOLEAN AS $$
|
|
BEGIN
|
|
-- Check for pre-paginated metadata
|
|
IF opf_content LIKE '%rendition:layout">pre-paginated<%' THEN
|
|
RETURN TRUE;
|
|
END IF;
|
|
|
|
IF opf_content LIKE '%rendition:layout="pre-paginated"%' THEN
|
|
RETURN TRUE;
|
|
END IF;
|
|
|
|
-- Check for RTL page progression (manga indicator)
|
|
IF opf_content LIKE '%page-progression-direction="rtl"%' THEN
|
|
RETURN TRUE;
|
|
END IF;
|
|
|
|
-- Check for image-heavy content (count <img> tags)
|
|
-- Threshold of 50 images suggests manga/comic vs novel
|
|
IF array_length(string_to_array(opf_content, '<img'), 1) - 1 > 50 THEN
|
|
RETURN TRUE;
|
|
END IF;
|
|
|
|
RETURN FALSE;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
|
|
-- Convert progress between format groups
|
|
CREATE OR REPLACE FUNCTION convert_progress(
|
|
source_format VARCHAR,
|
|
target_format VARCHAR,
|
|
source_progress JSONB
|
|
) RETURNS JSONB AS $$
|
|
DECLARE
|
|
result JSONB;
|
|
percentage FLOAT;
|
|
BEGIN
|
|
-- Extract percentage from source
|
|
CASE source_format
|
|
WHEN 'reflowable' THEN
|
|
percentage := (source_progress->>'percentage')::FLOAT;
|
|
WHEN 'fixed_layout' THEN
|
|
percentage := ((source_progress->>'page')::FLOAT /
|
|
(source_progress->>'total_pages')::FLOAT);
|
|
WHEN 'comic_archive' THEN
|
|
percentage := ((source_progress->>'page')::FLOAT /
|
|
(source_progress->>'total_pages')::FLOAT);
|
|
ELSE
|
|
percentage := 0.0;
|
|
END CASE;
|
|
|
|
-- Build target format progress
|
|
CASE target_format
|
|
WHEN 'reflowable' THEN
|
|
result := jsonb_build_object(
|
|
'percentage', percentage,
|
|
'epubcfi', 'epubcfi(/6/4/2:' || CAST(percentage * 100 AS INTEGER) || ')'
|
|
);
|
|
WHEN 'fixed_layout' THEN
|
|
result := jsonb_build_object(
|
|
'page', CAST(percentage * 200 AS INTEGER),
|
|
'total_pages', 200,
|
|
'percentage', percentage
|
|
);
|
|
WHEN 'comic_archive' THEN
|
|
result := jsonb_build_object(
|
|
'page', CAST(percentage * 32 AS INTEGER),
|
|
'total_pages', 32,
|
|
'percentage', percentage
|
|
);
|
|
ELSE
|
|
result := '{}'::jsonb;
|
|
END CASE;
|
|
|
|
RETURN result;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Detect conflicts in sync progress
|
|
CREATE OR REPLACE FUNCTION detect_conflict(
|
|
p_media_item_id UUID,
|
|
new_progress JSONB,
|
|
device_type VARCHAR
|
|
) RETURNS BOOLEAN AS $$
|
|
DECLARE
|
|
existing_progress JSONB;
|
|
time_diff INTERVAL;
|
|
BEGIN
|
|
-- Get most recent progress from different device
|
|
SELECT jsonb_build_object(
|
|
'percentage', percentage,
|
|
'timestamp', last_sync_timestamp
|
|
) INTO existing_progress
|
|
FROM reading_progress
|
|
WHERE media_item_id = p_media_item_id
|
|
AND last_sync_source != device_type
|
|
ORDER BY last_sync_timestamp DESC
|
|
LIMIT 1;
|
|
|
|
-- If no existing progress, no conflict
|
|
IF existing_progress IS NULL THEN
|
|
RETURN FALSE;
|
|
END IF;
|
|
|
|
-- Check time difference (within 5 minutes = potential conflict)
|
|
time_diff := NOW() - (existing_progress->>'timestamp')::TIMESTAMP WITH TIME ZONE;
|
|
|
|
IF time_diff < INTERVAL '5 minutes' THEN
|
|
-- Check if progress significantly different
|
|
IF ABS((new_progress->>'percentage')::FLOAT -
|
|
(existing_progress->>'percentage')::FLOAT) > 0.01 THEN
|
|
RETURN TRUE;
|
|
END IF;
|
|
END IF;
|
|
|
|
RETURN FALSE;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Merge progress from two sources
|
|
CREATE OR REPLACE FUNCTION merge_progress(
|
|
progress_a JSONB,
|
|
progress_b JSONB
|
|
) RETURNS JSONB AS $$
|
|
BEGIN
|
|
RETURN jsonb_build_object(
|
|
'percentage', GREATEST(
|
|
(progress_a->>'percentage')::FLOAT,
|
|
(progress_b->>'percentage')::FLOAT
|
|
),
|
|
'merged_from', ARRAY[
|
|
(progress_a->>'source'),
|
|
(progress_b->>'source')
|
|
],
|
|
'merge_timestamp', NOW()
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ============================================
|
|
-- KOREADER SYNC FUNCTIONS
|
|
-- ============================================
|
|
|
|
-- Bulk update progress from KOReader sync data
|
|
CREATE OR REPLACE FUNCTION bulk_update_progress_from_koreader(
|
|
p_user_id UUID,
|
|
p_sync_data JSONB
|
|
) RETURNS TABLE (
|
|
media_item_id UUID,
|
|
success BOOLEAN,
|
|
message TEXT
|
|
) AS $$
|
|
DECLARE
|
|
book_record JSONB;
|
|
media_uuid UUID;
|
|
existing_progress reading_progress%ROWTYPE;
|
|
conflict_detected BOOLEAN;
|
|
BEGIN
|
|
FOR book_record IN SELECT * FROM jsonb_array_elements(p_sync_data->'books')
|
|
LOOP
|
|
-- Try to find media item by UUID
|
|
media_uuid := NULL;
|
|
SELECT id INTO media_uuid FROM media_items WHERE id = (book_record->>'uuid')::uuid;
|
|
|
|
-- If not found by UUID, try to match by file path
|
|
IF media_uuid IS NULL AND book_record ? 'file_path' THEN
|
|
SELECT id INTO media_uuid FROM media_items WHERE file_path = book_record->>'file_path';
|
|
END IF;
|
|
|
|
-- If still not found, try to match by title + author
|
|
IF media_uuid IS NULL THEN
|
|
SELECT id INTO media_uuid FROM media_items
|
|
WHERE title = book_record->>'title'
|
|
AND author = book_record->>'author'
|
|
LIMIT 1;
|
|
END IF;
|
|
|
|
-- If no match found, return failure
|
|
IF media_uuid IS NULL THEN
|
|
RETURN QUERY SELECT NULL::uuid, FALSE, 'media item not found';
|
|
CONTINUE;
|
|
END IF;
|
|
|
|
-- Check for conflicts
|
|
conflict_detected := detect_conflict(media_uuid, book_record, 'koreader');
|
|
|
|
-- Check if progress exists
|
|
SELECT * INTO existing_progress FROM reading_progress
|
|
WHERE media_item_id = media_uuid AND user_id = p_user_id;
|
|
|
|
-- Update or insert progress
|
|
IF existing_progress.media_item_id IS NOT NULL THEN
|
|
UPDATE reading_progress SET
|
|
percentage = (book_record->>'percentage')::FLOAT,
|
|
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
|
|
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
|
|
context_text = CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE existing_progress.context_text END,
|
|
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
|
|
chapter_progress = (book_record->>'percentage')::FLOAT,
|
|
last_sync_device = 'koreader',
|
|
last_sync_source = 'koreader',
|
|
last_sync_timestamp = NOW(),
|
|
conflict_detected = conflict_detected,
|
|
conflict_resolved = NOT conflict_detected,
|
|
last_read_at = CASE WHEN book_record ? 'last_read' THEN (book_record->>'last_read')::TIMESTAMP WITH TIME ZONE ELSE NOW() END,
|
|
current_page = CASE WHEN book_record ? 'page' THEN (book_record->>'page')::INTEGER ELSE existing_progress.current_page END,
|
|
total_pages = CASE WHEN book_record ? 'total_pages' THEN (book_record->>'total_pages')::INTEGER ELSE existing_progress.total_pages END
|
|
WHERE media_item_id = media_uuid AND user_id = p_user_id;
|
|
ELSE
|
|
INSERT INTO reading_progress (
|
|
media_item_id,
|
|
user_id,
|
|
percentage,
|
|
character_offset,
|
|
epubcfi,
|
|
context_text,
|
|
chapter,
|
|
chapter_progress,
|
|
last_sync_device,
|
|
last_sync_source,
|
|
last_sync_timestamp,
|
|
conflict_detected,
|
|
conflict_resolved,
|
|
last_read_at,
|
|
current_page,
|
|
total_pages
|
|
) VALUES (
|
|
media_uuid,
|
|
p_user_id,
|
|
(book_record->>'percentage')::FLOAT,
|
|
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
|
|
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
|
|
CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END,
|
|
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
|
(book_record->>'percentage')::FLOAT,
|
|
'koreader',
|
|
'koreader',
|
|
NOW(),
|
|
conflict_detected,
|
|
NOT conflict_detected,
|
|
CASE WHEN book_record ? 'last_read' THEN (book_record->>'last_read')::TIMESTAMP WITH TIME ZONE ELSE NOW() END,
|
|
CASE WHEN book_record ? 'page' THEN (book_record->>'page')::INTEGER ELSE NULL END,
|
|
CASE WHEN book_record ? 'total_pages' THEN (book_record->>'total_pages')::INTEGER ELSE NULL END
|
|
);
|
|
END IF;
|
|
|
|
-- Create conflict record if detected
|
|
IF conflict_detected THEN
|
|
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data)
|
|
VALUES (media_uuid, p_user_id, 'progress', jsonb_build_object(
|
|
'koreader', book_record,
|
|
'timestamp', NOW()
|
|
));
|
|
END IF;
|
|
|
|
RETURN QUERY SELECT media_uuid, TRUE,
|
|
CASE WHEN conflict_detected THEN 'conflict detected' ELSE 'success' END;
|
|
END LOOP;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ============================================
|
|
--: UNIVERSAL BOOK IDENTIFIERS
|
|
-- ============================================
|
|
|
|
-- Add universal identifier columns to media_items table
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS file_sha256 CHAR(64);
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS opf_identifier VARCHAR(255);
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS opf_uuid VARCHAR(255);
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS hash_confidence VARCHAR(20);
|
|
|
|
-- Create indexes for fast lookup
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_sha256 ON media_items(file_sha256);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_opf_identifier ON media_items(opf_identifier);
|
|
|
|
-- Create media_item_formats table (track all format versions with their hashes)
|
|
CREATE TABLE IF NOT EXISTS media_item_formats (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
format_type VARCHAR(10) NOT NULL,
|
|
file_path VARCHAR(500),
|
|
file_sha256 CHAR(64),
|
|
file_size_bytes BIGINT,
|
|
mime_type VARCHAR(100),
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
converted_from_format_id UUID REFERENCES media_item_formats(id),
|
|
UNIQUE(media_item_id, format_type)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_media_item_formats_media ON media_item_formats(media_item_id, format_type);
|
|
CREATE INDEX IF NOT EXISTS idx_media_item_formats_sha256 ON media_item_formats(file_sha256);
|
|
|
|
-- Create device_file_aliases table (track file paths per device for cross-device matching)
|
|
CREATE TABLE IF NOT EXISTS device_file_aliases (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
file_path VARCHAR(500) NOT NULL,
|
|
file_sha256 CHAR(64),
|
|
confidence_score FLOAT DEFAULT 1.0,
|
|
last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(device_id, file_path)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_device_file_aliases_media_device ON device_file_aliases(media_item_id, device_id);
|
|
CREATE INDEX IF NOT EXISTS idx_device_file_aliases_sha256 ON device_file_aliases(file_sha256);
|
|
|
|
-- Create collections table (device-neutral collections)
|
|
CREATE TABLE IF NOT EXISTS collections (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
name VARCHAR(100) NOT NULL,
|
|
description TEXT,
|
|
color VARCHAR(7),
|
|
icon VARCHAR(50),
|
|
auto_assign_rules JSONB,
|
|
view_settings JSONB,
|
|
show_on_dashboard BOOLEAN DEFAULT false,
|
|
query_type TEXT DEFAULT 'filter',
|
|
priority INT DEFAULT 100,
|
|
is_system_collection BOOLEAN DEFAULT false,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(user_id, name)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
|
|
|
|
-- Index for dashboard queries
|
|
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
|
|
WHERE show_on_dashboard = true;
|
|
|
|
-- Create user_dashboard_preferences table
|
|
CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
library_id UUID REFERENCES libraries(id) ON DELETE CASCADE,
|
|
hidden_collections TEXT[] DEFAULT '{}',
|
|
collection_order TEXT[] DEFAULT '{}',
|
|
items_per_section INT DEFAULT 20,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(user_id, library_id)
|
|
);
|
|
|
|
-- Index for fast lookups
|
|
CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
|
|
|
|
-- Create collection_items table (which books belong to each collection)
|
|
CREATE TABLE IF NOT EXISTS collection_items (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
excluded BOOLEAN DEFAULT false,
|
|
UNIQUE(collection_id, media_item_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_id);
|
|
CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_item_id);
|
|
|
|
-- Index for excluding auto-assigned items
|
|
CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded)
|
|
WHERE excluded = true;
|
|
|
|
-- System collections are now created per-user upon registration
|
|
-- See CreateDefaultCollectionsForUser in auth.go
|
|
|
|
-- Create device_shelf_mappings table (map Bookhoard collections to device-specific shelf names)
|
|
CREATE TABLE IF NOT EXISTS device_shelf_mappings (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
device_shelf_name VARCHAR(100),
|
|
sync_direction VARCHAR(20),
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(collection_id, device_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_device_shelf_mappings_collection ON device_shelf_mappings(collection_id);
|
|
CREATE INDEX IF NOT EXISTS idx_device_shelf_mappings_device ON device_shelf_mappings(device_id);
|
|
|
|
-- Create device_catalogs table (track OPDS downloads and map Bookhoard UUIDs to device ContentIds)
|
|
CREATE TABLE IF NOT EXISTS device_catalogs (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
bookhoard_uuid UUID NOT NULL,
|
|
kobo_content_id VARCHAR(255) NOT NULL,
|
|
content_id_type VARCHAR(20),
|
|
available BOOLEAN DEFAULT TRUE,
|
|
delivery_date TIMESTAMP WITH TIME ZONE,
|
|
delivery_method VARCHAR(20),
|
|
UNIQUE(device_id, kobo_content_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_device_catalogs_bookhoard ON device_catalogs(bookhoard_uuid);
|
|
CREATE INDEX IF NOT EXISTS idx_device_catalogs_kobo ON device_catalogs(kobo_content_id);
|
|
|
|
-- Create system_config table (system-wide configuration)
|
|
CREATE TABLE IF NOT EXISTS system_config (
|
|
key VARCHAR(100) PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_by UUID REFERENCES users(id)
|
|
);
|
|
|
|
-- One-time cleanup: clear the old placeholder seed so the startup logic
|
|
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
|
|
UPDATE system_config SET value = ''
|
|
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
|
|
UPDATE system_config SET value = ''
|
|
WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds';
|
|
UPDATE system_config SET value = ''
|
|
WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api';
|
|
|
|
-- Create opds_tokens table (device-specific OPDS access tokens)
|
|
CREATE TABLE IF NOT EXISTS opds_tokens (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
token VARCHAR(64) UNIQUE NOT NULL,
|
|
token_type VARCHAR(20),
|
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_opds_tokens_device ON opds_tokens(device_id);
|
|
CREATE INDEX IF NOT EXISTS idx_opds_tokens_token ON opds_tokens(token);
|
|
|
|
-- Modify kobo_shelves table (reference collections instead of media_items directly)
|
|
ALTER TABLE kobo_shelves ADD COLUMN IF NOT EXISTS collection_id UUID REFERENCES collections(id);
|
|
ALTER TABLE kobo_shelves ADD COLUMN IF NOT EXISTS position_in_collection INTEGER;
|
|
|
|
-- ============================================
|
|
--: UNLINKED BOOKS TRACKING
|
|
-- ============================================
|
|
|
|
-- Create unlinked_books table to track books that couldn't be auto-matched
|
|
CREATE TABLE IF NOT EXISTS unlinked_books (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
content_id VARCHAR(255) NOT NULL,
|
|
file_path VARCHAR(500),
|
|
title VARCHAR(255),
|
|
author VARCHAR(255),
|
|
confidence_score FLOAT DEFAULT 0.5,
|
|
resolved BOOLEAN DEFAULT FALSE,
|
|
media_item_id UUID REFERENCES media_items(id) ON DELETE SET NULL,
|
|
resolved_at TIMESTAMP WITH TIME ZONE,
|
|
resolution_method VARCHAR(50),
|
|
last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
|
|
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
|
|
CREATE INDEX IF NOT EXISTS idx_unlinked_books_resolved ON unlinked_books(resolved);
|
|
|
|
-- Create saved_filters for users to be able to filter different parts of the application
|
|
CREATE TABLE IF NOT EXISTS saved_filters (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
resource_type TEXT NOT NULL, -- 'media-items', 'collections', 'devices', etc.
|
|
filters JSONB NOT NULL, -- {search: "", author_filter: "", genre: "", ...}
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Index for efficient user+resource lookups
|
|
CREATE INDEX IF NOT EXISTS idx_saved_filters_user_resource ON saved_filters(user_id, resource_type);
|
|
|
|
-- Index for name searches (future feature)
|
|
CREATE INDEX IF NOT EXISTS idx_saved_filters_name ON saved_filters(user_id, name);
|
|
|
|
-- Unique constraint: One filter name per user per resource type
|
|
-- Prevents duplicate filter names while allowing:
|
|
-- - Same name for different users
|
|
-- - Same name for different resources (media-items vs collections)
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_filters_user_name_resource
|
|
ON saved_filters(user_id, name, resource_type);
|
|
|
|
-- Trigger to auto-update updated_at timestamp
|
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = NOW();
|
|
RETURN NEW;
|
|
END;
|
|
$$ language 'plpgsql';
|
|
|
|
DROP TRIGGER IF EXISTS update_saved_filters_updated_at ON saved_filters;
|
|
|
|
CREATE TRIGGER update_saved_filters_updated_at
|
|
BEFORE UPDATE ON saved_filters
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION update_updated_at_column();
|
|
|
|
-- Panel detection data
|
|
CREATE TABLE IF NOT EXISTS panel_data (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
page_number INTEGER NOT NULL,
|
|
detection_method VARCHAR(20) NOT NULL, -- 'grid', 'ml', 'manual'
|
|
panels JSONB NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
UNIQUE(media_item_id, page_number)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_panel_data_media_item ON panel_data(media_item_id);
|
|
|
|
-- Reading speed tracking
|
|
CREATE TABLE IF NOT EXISTS reading_speed (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
words_per_minute REAL,
|
|
pages_per_minute REAL,
|
|
pages_read INTEGER DEFAULT 0,
|
|
total_reading_minutes REAL DEFAULT 0,
|
|
last_read_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
UNIQUE(user_id, media_item_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);
|
|
|
|
-- Dictionary cache (for offline use)
|
|
CREATE TABLE IF NOT EXISTS dictionary_cache (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
word VARCHAR(100) NOT NULL UNIQUE,
|
|
definition TEXT NOT NULL,
|
|
part_of_speech VARCHAR(20),
|
|
example TEXT,
|
|
etymology TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
accessed_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_dictionary_word ON dictionary_cache(word);
|
|
|
|
-- Reader settings (per-user preferences)
|
|
CREATE TABLE IF NOT EXISTS reader_settings (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
setting_key VARCHAR(50) NOT NULL,
|
|
setting_value JSONB NOT NULL,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
UNIQUE(user_id, setting_key)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id);
|
|
|
|
-- PDF bookmarks (custom user bookmarks)
|
|
CREATE TABLE IF NOT EXISTS media_bookmarks (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
page_number INTEGER,
|
|
chapter_number INTEGER,
|
|
cfi_position VARCHAR(255), -- For ebooks: EPUB CFI position
|
|
title VARCHAR(255) NOT NULL,
|
|
position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
|
|
notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
UNIQUE(media_item_id, user_id, title)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
|
|
|
|
-- ============================================
|
|
-- ANNOTATION SYNC MIGRATIONS
|
|
-- Adds dedup_key, LWW timestamps, soft-delete,
|
|
-- and device_sync_data to annotation tables.
|
|
-- ============================================
|
|
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
|
|
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
|
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
|
|
ON media_highlights (user_id, media_item_id, dedup_key)
|
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup
|
|
ON media_notes (user_id, media_item_id, dedup_key)
|
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup
|
|
ON media_bookmarks (user_id, media_item_id, dedup_key)
|
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
|
|
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
|
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;
|