ListDeletedAnnotationsForBook unions tombstoned highlights, notes, and bookmarks for a user+book regardless of the sync TTL cutoff (the history must show everything still restorable, not just recent deletes), with display text, secondary text, color, and both timestamps. Restore queries clear deleted/deleted_at (lossless — the row was soft- deleted, never removed) and are scoped to the owning user and media item so a restore can never touch another user's annotation. Purge queries hard-delete an already-tombstoned row: the user-driven counterpart of the TTL maintenance sweep, for explicit 'delete permanently' actions from the history. All six write queries are :execrows so callers can distinguish 'restored' from 'nothing matched' without a follow-up read.
2668 lines
80 KiB
SQL
2668 lines
80 KiB
SQL
-- name: CreateUser :one
|
|
INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, email, username, theme, first_name, last_name, role, created_at, updated_at;
|
|
|
|
-- name: GetUserByEmail :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1;
|
|
|
|
-- name: GetUserByUsername :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE username = $1;
|
|
|
|
-- name: GetUserByEmailOrUsername :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1 OR username = $1;
|
|
|
|
-- name: GetUserForLogin :one
|
|
SELECT id, email, username, password_hash, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1 OR username = $1;
|
|
|
|
-- name: GetUser :one
|
|
SELECT
|
|
u.id,
|
|
u.email,
|
|
u.username,
|
|
u.theme,
|
|
u.first_name,
|
|
u.last_name,
|
|
u.role,
|
|
u.max_devices,
|
|
u.created_at,
|
|
u.updated_at,
|
|
u.timezone,
|
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
|
FROM users u
|
|
WHERE u.id = $1;
|
|
|
|
-- name: GetUserPasswordHash :one
|
|
SELECT password_hash FROM users WHERE id = $1;
|
|
|
|
-- name: ListUsers :many
|
|
SELECT
|
|
u.id,
|
|
u.email,
|
|
u.username,
|
|
u.theme,
|
|
u.first_name,
|
|
u.last_name,
|
|
u.role,
|
|
u.max_devices,
|
|
u.created_at,
|
|
u.updated_at,
|
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
|
FROM users u
|
|
ORDER BY u.created_at DESC;
|
|
|
|
-- Library Types queries
|
|
-- name: GetLibraryTypes :many
|
|
SELECT * FROM library_types ORDER BY name;
|
|
|
|
-- name: GetLibraryType :one
|
|
SELECT * FROM library_types WHERE id = $1;
|
|
|
|
-- name: GetLibraryTypeByName :one
|
|
SELECT * FROM library_types WHERE name = $1;
|
|
|
|
-- name: SyncLibraryTypeExtensions :exec
|
|
UPDATE library_types SET allowed_extensions = $2 WHERE name = $1;
|
|
|
|
-- Libraries queries
|
|
-- name: CreateLibrary :one
|
|
INSERT INTO libraries (name, description, library_type_id, created_by_admin_id)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING *;
|
|
|
|
-- name: GetLibrary :one
|
|
SELECT l.*, lt.name as type_name, lt.description as type_description
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE l.id = $1;
|
|
|
|
-- name: ListLibraries :many
|
|
SELECT l.*, lt.name as type_name, lt.description as type_description
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
ORDER BY l.created_at DESC;
|
|
|
|
-- name: UpdateLibrary :one
|
|
UPDATE libraries SET
|
|
name = $2,
|
|
description = $3,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteLibrary :exec
|
|
DELETE FROM libraries WHERE id = $1;
|
|
|
|
-- Library Folders queries
|
|
-- name: AddLibraryFolder :one
|
|
INSERT INTO library_folders (library_id, folder_path) VALUES ($1, $2) RETURNING *;
|
|
|
|
-- name: GetLibraryFolders :many
|
|
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
|
|
|
|
-- name: DeleteLibraryFolder :one
|
|
DELETE FROM library_folders WHERE library_id = $1 AND folder_path = $2 RETURNING *;
|
|
|
|
-- name: GetLibraryByFolder :one
|
|
SELECT lf.library_id, l.* FROM library_folders lf
|
|
JOIN libraries l ON lf.library_id = l.id
|
|
WHERE lf.folder_path = $1;
|
|
|
|
-- name: GetLibraryByFolderPathPrefix :one
|
|
SELECT lf.library_id, lf.folder_path, l.* FROM library_folders lf
|
|
JOIN libraries l ON lf.library_id = l.id
|
|
WHERE $1 LIKE lf.folder_path || '%'
|
|
ORDER BY LENGTH(lf.folder_path) DESC
|
|
LIMIT 1;
|
|
|
|
-- Library Visibility queries
|
|
-- name: SetLibraryVisibility :one
|
|
INSERT INTO library_visibility (user_id, library_id, is_visible)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (user_id, library_id)
|
|
DO UPDATE SET
|
|
is_visible = EXCLUDED.is_visible,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetLibraryVisibility :one
|
|
SELECT * FROM library_visibility WHERE user_id = $1 AND library_id = $2;
|
|
|
|
-- name: GetUserVisibleLibraries :many
|
|
SELECT l.*, lt.name as type_name, lt.description as type_description,
|
|
COALESCE(lv.is_visible, true) as is_visible
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
ORDER BY l.created_at ASC;
|
|
|
|
-- name: GetVisibleLibraryMediaCounts :many
|
|
SELECT l.id, COUNT(mi.id) as media_count
|
|
FROM libraries l
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
LEFT JOIN media_items mi ON mi.library_id = l.id
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
GROUP BY l.id;
|
|
|
|
-- Media Items queries
|
|
-- name: CreateMediaItem :one
|
|
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
|
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaItem :one
|
|
SELECT * FROM media_items WHERE id = $1;
|
|
|
|
-- name: ListMediaItems :many
|
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
|
|
|
-- name: ListMediaItemsByLibrary :many
|
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE mi.library_id = $1
|
|
ORDER BY mi.created_at DESC;
|
|
|
|
-- name: ListMediaItemsSorted :many
|
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE mi.library_id = sqlc.narg('library_id')
|
|
ORDER BY
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'series ASC' THEN COALESCE(mi.series, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'series DESC' THEN COALESCE(mi.series, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'date_published ASC' THEN COALESCE(mi.date_published::text, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'date_published DESC' THEN COALESCE(mi.date_published::text, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'copyright_year ASC' THEN COALESCE(mi.copyright_year::text, '0')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'copyright_year DESC' THEN COALESCE(mi.copyright_year::text, '0')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'genre ASC' THEN COALESCE(mi.genre, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'genre DESC' THEN COALESCE(mi.genre, '')
|
|
ELSE ''
|
|
END DESC,
|
|
mi.created_at DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: UpdateMediaItem :one
|
|
UPDATE media_items SET
|
|
title = $2,
|
|
author = $3,
|
|
isbn = $4,
|
|
description = $5,
|
|
cover_image_path = $6,
|
|
series = $7,
|
|
series_number = $8,
|
|
tags = $9,
|
|
tags_search = $10,
|
|
asin = $11,
|
|
date_published = $12,
|
|
publisher = $13,
|
|
contributors = $14,
|
|
contributors_search = $15,
|
|
language = $16,
|
|
edition = $17,
|
|
page_count = $18,
|
|
genre = $19,
|
|
copyright_year = $20,
|
|
goodreads_id = $21,
|
|
openlibrary_id = $22,
|
|
google_books_id = $23,
|
|
manga_type = $24,
|
|
reading_direction = $25,
|
|
series_count = $26,
|
|
volume = $27,
|
|
imprint = $28,
|
|
age_rating = $29,
|
|
web_url = $30,
|
|
metadata_notes = $31,
|
|
community_rating = $32,
|
|
story_arc = $33,
|
|
is_black_and_white = $34,
|
|
alternate_info = $35,
|
|
scan_information = $36,
|
|
summary = $37,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteMediaItem :exec
|
|
DELETE FROM media_items WHERE id = $1;
|
|
|
|
-- name: GetMediaItemByFilePath :one
|
|
SELECT * FROM media_items WHERE file_path = $1 AND library_id = $2;
|
|
|
|
-- name: GetMediaItemByFilePathAnyLibrary :one
|
|
SELECT * FROM media_items WHERE file_path = $1 LIMIT 1;
|
|
|
|
-- name: GetReadingProgress :one
|
|
SELECT * FROM reading_progress WHERE media_item_id = $1 AND user_id = $2;
|
|
|
|
-- name: UpdateReadingProgress :one
|
|
INSERT INTO reading_progress (media_item_id, user_id, current_page, total_pages, last_read_at)
|
|
VALUES ($1, $2, $3, $4, NOW())
|
|
ON CONFLICT (media_item_id, user_id)
|
|
DO UPDATE SET
|
|
current_page = EXCLUDED.current_page,
|
|
total_pages = EXCLUDED.total_pages,
|
|
last_read_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: DeleteReadingProgress :exec
|
|
DELETE FROM reading_progress WHERE media_item_id = $1 AND user_id = $2;
|
|
|
|
-- name: UpdateUserTheme :exec
|
|
UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdateUserProfile :exec
|
|
UPDATE users SET first_name = $2, last_name = $3, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdateUsername :exec
|
|
UPDATE users SET username = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdateEmail :exec
|
|
UPDATE users SET email = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdatePassword :exec
|
|
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdateUserMaxDevices :one
|
|
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateUserRole :one
|
|
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
|
|
RETURNING id, email, username, role;
|
|
|
|
-- name: UpdateUserTimezone :exec
|
|
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: GetSystemTimezone :one
|
|
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
|
|
|
-- name: CountUserDevices :one
|
|
SELECT COUNT(*) FROM devices WHERE user_id = $1;
|
|
|
|
-- name: GetFirstAdminExclude :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, max_devices, created_at, updated_at FROM users
|
|
WHERE role = 'admin' AND id != $1
|
|
ORDER BY created_at ASC
|
|
LIMIT 1;
|
|
|
|
-- name: GetFirstAdmin :one
|
|
SELECT id FROM users
|
|
WHERE role = 'admin'
|
|
ORDER BY created_at ASC
|
|
LIMIT 1;
|
|
|
|
-- name: CountAdmins :one
|
|
SELECT COUNT(*) FROM users WHERE role = 'admin';
|
|
|
|
-- name: ReassignLibraries :exec
|
|
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
|
|
|
|
-- name: ReassignMediaItems :exec
|
|
UPDATE media_items SET added_by_admin_id = $2 WHERE added_by_admin_id = $1;
|
|
|
|
-- name: DeleteUser :exec
|
|
DELETE FROM users WHERE id = $1;
|
|
|
|
-- System Settings queries
|
|
-- name: GetSystemSetting :one
|
|
SELECT setting_value FROM system_settings WHERE setting_key = $1;
|
|
|
|
-- name: UpdateSystemSetting :exec
|
|
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
|
|
|
|
-- name: UpsertSystemSetting :one
|
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (setting_key) DO UPDATE
|
|
SET setting_value = EXCLUDED.setting_value,
|
|
description = EXCLUDED.description,
|
|
setting_type = EXCLUDED.setting_type,
|
|
min_value = EXCLUDED.min_value,
|
|
max_value = EXCLUDED.max_value,
|
|
requires_restart = EXCLUDED.requires_restart,
|
|
category = EXCLUDED.category,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetSystemSettingFull :one
|
|
SELECT * FROM system_settings WHERE setting_key = $1;
|
|
|
|
-- name: GetAllSystemSettings :many
|
|
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
|
|
|
|
-- name: GetAllSystemSettingsFull :many
|
|
SELECT * FROM system_settings ORDER BY category, setting_key;
|
|
|
|
-- name: CreateMediaRating :one
|
|
INSERT INTO media_ratings (media_item_id, user_id, rating)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (media_item_id, user_id)
|
|
DO UPDATE SET
|
|
rating = EXCLUDED.rating,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaRating :one
|
|
SELECT * FROM media_ratings WHERE media_item_id = $1 AND user_id = $2;
|
|
|
|
-- name: GetMediaRatings :many
|
|
SELECT mr.*, u.username
|
|
FROM media_ratings mr
|
|
JOIN users u ON mr.user_id = u.id
|
|
WHERE mr.media_item_id = $1
|
|
ORDER BY mr.created_at DESC;
|
|
|
|
-- name: UpdateMediaRating :one
|
|
UPDATE media_ratings SET
|
|
rating = $3,
|
|
updated_at = NOW()
|
|
WHERE media_item_id = $1 AND user_id = $2
|
|
RETURNING *;
|
|
|
|
-- name: DeleteMediaRating :exec
|
|
DELETE FROM media_ratings WHERE media_item_id = $1 AND user_id = $2;
|
|
|
|
-- Search Media Items queries
|
|
-- name: SearchMediaItems :many
|
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (
|
|
mi.title ILIKE sqlc.narg('search_pattern') OR
|
|
mi.author ILIKE sqlc.narg('search_pattern') OR
|
|
mi.series ILIKE sqlc.narg('search_pattern') OR
|
|
sqlc.narg('search_pattern') = ANY(mi.tags_search) OR
|
|
sqlc.narg('search_pattern') = ANY(mi.contributors_search)
|
|
)
|
|
ORDER BY
|
|
CASE
|
|
WHEN mi.title ILIKE sqlc.narg('search_pattern') THEN 1
|
|
WHEN mi.author ILIKE sqlc.narg('search_pattern') THEN 2
|
|
WHEN mi.series ILIKE sqlc.narg('search_pattern') THEN 3
|
|
WHEN sqlc.narg('search_pattern') = ANY(mi.tags_search) THEN 4
|
|
ELSE 5
|
|
END,
|
|
mi.title ASC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchMediaItemsUnified :many
|
|
SELECT
|
|
mi.id,
|
|
mi.library_id,
|
|
mi.title,
|
|
mi.author,
|
|
mi.isbn,
|
|
mi.description,
|
|
mi.file_path,
|
|
mi.file_size,
|
|
mi.mime_type,
|
|
mi.cover_image_path,
|
|
mi.series,
|
|
mi.series_number,
|
|
mi.tags,
|
|
mi.asin,
|
|
mi.date_published,
|
|
mi.publisher,
|
|
mi.contributors,
|
|
mi.language,
|
|
mi.edition,
|
|
mi.page_count,
|
|
mi.genre,
|
|
mi.copyright_year,
|
|
mi.goodreads_id,
|
|
mi.openlibrary_id,
|
|
mi.google_books_id,
|
|
mi.added_by_admin_id,
|
|
mi.created_at,
|
|
mi.updated_at,
|
|
mi.format_group,
|
|
mi.format_mimetype,
|
|
mi.is_reflowable,
|
|
mi.has_fixed_layout,
|
|
mi.total_characters,
|
|
mi.chapter_count,
|
|
mi.entitlement_id,
|
|
mi.revision_number,
|
|
mi.kobo_content_id,
|
|
mi.kobo_metadata,
|
|
mi.tags_search,
|
|
mi.contributors_search,
|
|
mi.file_sha256,
|
|
mi.opf_identifier,
|
|
mi.opf_uuid,
|
|
mi.hash_confidence,
|
|
l.name as library_name,
|
|
lt.name as library_type_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (sqlc.narg('library_id')::uuid IS NULL
|
|
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
-- Fuzzy author filter
|
|
AND (sqlc.narg('author_filter') = '' OR word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')) > 0.3)
|
|
-- Fuzzy series filter
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
-- Fuzzy genre filter
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
-- Fuzzy language filter
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
-- Tags filter (NEW - fuzzy match against tags array)
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
-- Year range (exact) - prioritize date_published, fallback to copyright_year
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
-- Boolean (exact)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR -- Not specified = show all
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
-- Search query (fuzzy or exact based on quotes)
|
|
AND (
|
|
sqlc.narg('search_query') = '' OR
|
|
-- Fuzzy search (default)
|
|
sqlc.narg('is_exact_search') = false AND (
|
|
word_similarity(sqlc.narg('search_query'), mi.title) > 0.3 OR
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 OR
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 OR
|
|
EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('search_query'), tag) > 0.3
|
|
LIMIT 1
|
|
) OR
|
|
EXISTS (
|
|
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
|
|
WHERE word_similarity(sqlc.narg('search_query'), contributor) > 0.3
|
|
LIMIT 1
|
|
)
|
|
) OR
|
|
-- Exact search (with quotes)
|
|
-- Keeping old pattern commented out in case we want wildcard exact back. See search.go line 62
|
|
-- sqlc.narg('is_exact_search') = true AND (
|
|
-- mi.title ILIKE sqlc.narg('search_pattern') OR
|
|
-- mi.author ILIKE sqlc.narg('search_pattern') OR
|
|
-- mi.series ILIKE sqlc.narg('search_pattern') OR
|
|
-- sqlc.narg('search_pattern') = ANY(mi.tags_search) OR
|
|
-- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
|
|
-- )
|
|
-- Exact search (with quotes) - true exact match, not substring
|
|
sqlc.narg('is_exact_search') = true AND (
|
|
mi.title = sqlc.narg('search_query') OR
|
|
COALESCE(mi.author, '') = sqlc.narg('search_query') OR
|
|
COALESCE(mi.series, '') = sqlc.narg('search_query') OR
|
|
sqlc.narg('search_query') = ANY(mi.tags_search) OR
|
|
sqlc.narg('search_query') = ANY(mi.contributors_search)
|
|
)
|
|
)
|
|
ORDER BY
|
|
-- Primary sort: relevance score when searching
|
|
CASE
|
|
WHEN sqlc.narg('search_query') != '' THEN
|
|
GREATEST(
|
|
CASE WHEN sqlc.narg('is_exact_search') = false THEN
|
|
word_similarity(sqlc.narg('search_query'), mi.title)
|
|
ELSE 0 END,
|
|
CASE WHEN sqlc.narg('is_exact_search') = false THEN
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))
|
|
ELSE 0 END,
|
|
word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
|
|
word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')),
|
|
(SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag))
|
|
FROM unnest(mi.tags_search) AS tag)
|
|
)
|
|
ELSE 0
|
|
END DESC,
|
|
-- Secondary sort: user-specified sort parameter
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END DESC,
|
|
-- Tertiary sort: title (default fallback)
|
|
mi.title ASC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchAuthorValues :many
|
|
SELECT
|
|
mi.author as value,
|
|
COUNT(*) as count,
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND mi.library_id = sqlc.narg('library_id')
|
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
|
|
AND mi.author IS NOT NULL
|
|
AND mi.author != ''
|
|
GROUP BY mi.author, word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchGenreValues :many
|
|
SELECT
|
|
mi.genre as value,
|
|
COUNT(*) as count,
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND mi.library_id = sqlc.narg('library_id')
|
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
|
|
AND mi.genre IS NOT NULL
|
|
AND mi.genre != ''
|
|
GROUP BY mi.genre, word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchTagsValues :many
|
|
SELECT
|
|
tag::TEXT as value,
|
|
COUNT(*) as count,
|
|
word_similarity(sqlc.narg('search_query'), tag)::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
CROSS JOIN LATERAL unnest(mi.tags_search) AS tag
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND tag IS NOT NULL
|
|
AND word_similarity(sqlc.narg('search_query'), tag) > 0.3
|
|
GROUP BY tag, word_similarity(sqlc.narg('search_query'), tag)::float8
|
|
ORDER BY word_similarity(sqlc.narg('search_query'), tag)::float8 DESC, COUNT DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchSeriesValues :many
|
|
SELECT
|
|
mi.series as value,
|
|
COUNT(*) as count,
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND mi.library_id = sqlc.narg('library_id')
|
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
|
|
AND mi.series IS NOT NULL
|
|
AND mi.series != ''
|
|
GROUP BY mi.series, word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchLanguageValues :many
|
|
SELECT
|
|
mi.language as value,
|
|
COUNT(*) as count,
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND mi.library_id = sqlc.narg('library_id')
|
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
|
|
AND mi.language IS NOT NULL
|
|
AND mi.language != ''
|
|
GROUP BY mi.language, word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- Media Notes queries
|
|
-- name: CreateMediaNote :one
|
|
INSERT INTO media_notes (media_item_id, user_id, content, position)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaNote :one
|
|
SELECT * FROM media_notes WHERE id = $1;
|
|
|
|
-- name: GetMediaNotes :many
|
|
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
|
|
|
-- name: UpdateMediaNote :one
|
|
UPDATE media_notes SET
|
|
content = $2,
|
|
position = $3,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteMediaNote :exec
|
|
DELETE FROM media_notes WHERE id = $1;
|
|
|
|
-- Media Highlights queries
|
|
-- name: CreateMediaHighlight :one
|
|
INSERT INTO media_highlights (media_item_id, user_id, selection_text, start_position, end_position, color, note_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaHighlight :one
|
|
SELECT * FROM media_highlights WHERE id = $1;
|
|
|
|
-- name: GetMediaHighlights :many
|
|
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
|
|
|
-- name: UpdateMediaHighlight :one
|
|
UPDATE media_highlights SET
|
|
selection_text = $2,
|
|
start_position = $3,
|
|
end_position = $4,
|
|
color = $5,
|
|
note_id = $6,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteMediaHighlight :exec
|
|
DELETE FROM media_highlights WHERE id = $1;
|
|
|
|
-- ============================================
|
|
-- ANNOTATION SYNC QUERIES (highlights)
|
|
-- ============================================
|
|
|
|
-- name: GetMediaHighlightByDedupKey :one
|
|
SELECT * FROM media_highlights
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
|
LIMIT 1;
|
|
|
|
-- name: CreateMediaHighlightFull :one
|
|
INSERT INTO media_highlights (
|
|
media_item_id, user_id, selection_text,
|
|
start_position, end_position, color, note_text,
|
|
percentage_start, percentage_end,
|
|
epubcfi_start, epubcfi_end,
|
|
chapter_reference,
|
|
dedup_key, last_modified_at, last_modified_source,
|
|
device_sync_data
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
|
) RETURNING *;
|
|
|
|
-- name: UpdateMediaHighlightForSync :one
|
|
UPDATE media_highlights SET
|
|
selection_text = $2,
|
|
start_position = $3,
|
|
end_position = $4,
|
|
color = $5,
|
|
note_text = $6,
|
|
percentage_start = $7,
|
|
percentage_end = $8,
|
|
epubcfi_start = $9,
|
|
epubcfi_end = $10,
|
|
chapter_reference = $11,
|
|
last_modified_at = $12,
|
|
last_modified_source = $13,
|
|
device_sync_data = $14,
|
|
updated_at = NOW(),
|
|
deleted = FALSE,
|
|
deleted_at = NULL
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: TombstoneMediaHighlightByDedupKey :exec
|
|
UPDATE media_highlights SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
|
|
|
-- name: TombstoneMediaHighlightByID :exec
|
|
UPDATE media_highlights SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- name: PurgeExpiredHighlightTombstones :exec
|
|
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1;
|
|
|
|
-- ============================================
|
|
-- ANNOTATION SYNC QUERIES (notes)
|
|
-- ============================================
|
|
|
|
-- name: GetMediaNoteByDedupKey :one
|
|
SELECT * FROM media_notes
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
|
LIMIT 1;
|
|
|
|
-- name: CreateMediaNoteFull :one
|
|
INSERT INTO media_notes (
|
|
media_item_id, user_id, content, position,
|
|
percentage_location, character_start, character_end,
|
|
epubcfi_location, chapter_reference, paragraph_reference,
|
|
dedup_key, last_modified_at, last_modified_source,
|
|
device_sync_data
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
|
) RETURNING *;
|
|
|
|
-- name: UpdateMediaNoteForSync :one
|
|
UPDATE media_notes SET
|
|
content = $2,
|
|
position = $3,
|
|
percentage_location = $4,
|
|
character_start = $5,
|
|
character_end = $6,
|
|
epubcfi_location = $7,
|
|
chapter_reference = $8,
|
|
paragraph_reference = $9,
|
|
last_modified_at = $10,
|
|
last_modified_source = $11,
|
|
device_sync_data = $12,
|
|
updated_at = NOW(),
|
|
deleted = FALSE,
|
|
deleted_at = NULL
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: TombstoneMediaNoteByDedupKey :exec
|
|
UPDATE media_notes SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
|
|
|
-- name: TombstoneMediaNoteByID :exec
|
|
UPDATE media_notes SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- name: PurgeExpiredNoteTombstones :exec
|
|
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1;
|
|
|
|
-- ============================================
|
|
-- ANNOTATION SYNC QUERIES (bookmarks)
|
|
-- ============================================
|
|
|
|
-- name: GetMediaBookmarkByDedupKey :one
|
|
SELECT * FROM media_bookmarks
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
|
LIMIT 1;
|
|
|
|
-- name: CreateMediaBookmarkFull :one
|
|
INSERT INTO media_bookmarks (
|
|
media_item_id, user_id, page_number, chapter_number,
|
|
cfi_position, title, position, notes,
|
|
percentage_location, epubcfi_location, chapter_reference,
|
|
dedup_key, last_modified_at, last_modified_source,
|
|
device_sync_data
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
|
) RETURNING *;
|
|
|
|
-- name: UpdateMediaBookmarkForSync :one
|
|
UPDATE media_bookmarks SET
|
|
page_number = $2,
|
|
chapter_number = $3,
|
|
cfi_position = $4,
|
|
title = $5,
|
|
position = $6,
|
|
notes = $7,
|
|
percentage_location = $8,
|
|
epubcfi_location = $9,
|
|
chapter_reference = $10,
|
|
last_modified_at = $11,
|
|
last_modified_source = $12,
|
|
device_sync_data = $13,
|
|
created_at = created_at,
|
|
deleted = FALSE,
|
|
deleted_at = NULL
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: TombstoneMediaBookmarkByDedupKey :exec
|
|
UPDATE media_bookmarks SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
|
|
|
-- name: TombstoneMediaBookmarkByID :exec
|
|
UPDATE media_bookmarks SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- name: PurgeExpiredBookmarkTombstones :exec
|
|
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1;
|
|
|
|
-- ============================================
|
|
-- ANNOTATION SERVE QUERIES
|
|
-- ============================================
|
|
|
|
-- name: GetActiveAnnotationsForBook :many
|
|
SELECT
|
|
mh.id,
|
|
mh.selection_text,
|
|
mh.start_position,
|
|
mh.end_position,
|
|
mh.color,
|
|
mh.created_at,
|
|
mh.updated_at,
|
|
'highlight' as annotation_type,
|
|
mh.percentage_start,
|
|
mh.percentage_end,
|
|
mh.epubcfi_start,
|
|
mh.epubcfi_end,
|
|
mh.note_text,
|
|
mh.dedup_key,
|
|
mh.last_modified_at,
|
|
mh.last_modified_source
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = FALSE
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.content,
|
|
mn.position,
|
|
NULL as end_position,
|
|
NULL as color,
|
|
mn.created_at,
|
|
mn.updated_at,
|
|
'note' as annotation_type,
|
|
mn.percentage_location as percentage_start,
|
|
NULL as percentage_end,
|
|
mn.epubcfi_location as epubcfi_start,
|
|
NULL as epubcfi_end,
|
|
NULL as note_text,
|
|
mn.dedup_key,
|
|
mn.last_modified_at,
|
|
mn.last_modified_source
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = FALSE
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: GetTombstonedAnnotationsForBook :many
|
|
SELECT
|
|
mh.id,
|
|
mh.dedup_key,
|
|
'highlight' as annotation_type,
|
|
mh.device_sync_data,
|
|
mh.deleted_at,
|
|
mh.start_position,
|
|
mh.end_position,
|
|
mh.epubcfi_start,
|
|
mh.epubcfi_end
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.dedup_key,
|
|
'note' as annotation_type,
|
|
mn.device_sync_data,
|
|
mn.deleted_at,
|
|
mn.position as start_position,
|
|
NULL as end_position,
|
|
mn.epubcfi_location as epubcfi_start,
|
|
NULL as epubcfi_end
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
|
UNION ALL
|
|
SELECT
|
|
mb.id,
|
|
mb.dedup_key,
|
|
'bookmark' as annotation_type,
|
|
mb.device_sync_data,
|
|
mb.deleted_at,
|
|
mb.position as start_position,
|
|
NULL as end_position,
|
|
mb.cfi_position as epubcfi_start,
|
|
NULL as epubcfi_end
|
|
FROM media_bookmarks mb
|
|
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
|
ORDER BY deleted_at DESC;
|
|
|
|
-- ============================================
|
|
-- ANNOTATION HISTORY (deleted-annotation archive)
|
|
-- ============================================
|
|
|
|
-- Lists every currently-tombstoned annotation for a book regardless of the
|
|
-- tombstone TTL: this backs the book page's "recently deleted" history where
|
|
-- users can restore or permanently remove entries. Rows whose tombstones have
|
|
-- been purged by the daily maintenance sweep no longer exist at all.
|
|
-- name: ListDeletedAnnotationsForBook :many
|
|
SELECT
|
|
mh.id,
|
|
mh.dedup_key,
|
|
'highlight' as annotation_type,
|
|
mh.selection_text as display_text,
|
|
mh.note_text as secondary_text,
|
|
mh.color,
|
|
mh.deleted_at,
|
|
mh.created_at
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.dedup_key,
|
|
'note' as annotation_type,
|
|
mn.content as display_text,
|
|
NULL::text as secondary_text,
|
|
NULL::text as color,
|
|
mn.deleted_at,
|
|
mn.created_at
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE
|
|
UNION ALL
|
|
SELECT
|
|
mb.id,
|
|
mb.dedup_key,
|
|
'bookmark' as annotation_type,
|
|
mb.title as display_text,
|
|
mb.notes as secondary_text,
|
|
NULL::text as color,
|
|
mb.deleted_at,
|
|
mb.created_at
|
|
FROM media_bookmarks mb
|
|
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE
|
|
ORDER BY deleted_at DESC;
|
|
|
|
-- name: RestoreMediaHighlightByID :execrows
|
|
UPDATE media_highlights SET
|
|
deleted = FALSE,
|
|
deleted_at = NULL,
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- name: RestoreMediaNoteByID :execrows
|
|
UPDATE media_notes SET
|
|
deleted = FALSE,
|
|
deleted_at = NULL,
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- name: RestoreMediaBookmarkByID :execrows
|
|
UPDATE media_bookmarks SET
|
|
deleted = FALSE,
|
|
deleted_at = NULL,
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- Permanent removal from the history (distinct from the TTL-driven purge,
|
|
-- which is maintenance). Scoped to the owning user and book.
|
|
-- name: PurgeMediaHighlightByID :execrows
|
|
DELETE FROM media_highlights
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- name: PurgeMediaNoteByID :execrows
|
|
DELETE FROM media_notes
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- name: PurgeMediaBookmarkByID :execrows
|
|
DELETE FROM media_bookmarks
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- Refresh Tokens queries
|
|
-- name: CreateRefreshToken :one
|
|
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING *;
|
|
|
|
-- name: GetRefreshToken :one
|
|
SELECT rt.*, u.email, u.username, u.role
|
|
FROM refresh_tokens rt
|
|
JOIN users u ON rt.user_id = u.id
|
|
WHERE rt.token = $1 AND rt.revoked_at IS NULL AND rt.expires_at > NOW();
|
|
|
|
-- name: RevokeRefreshToken :exec
|
|
UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1;
|
|
|
|
-- name: RevokeAllUserRefreshTokens :exec
|
|
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
|
|
|
|
-- name: CleanupExpiredRefreshTokens :exec
|
|
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision));
|
|
|
|
-- ============================================
|
|
-- FORMAT DETECTION & PROGRESS
|
|
-- ============================================
|
|
|
|
-- Update media item format group information
|
|
-- name: UpdateMediaItemFormatGroup :exec
|
|
UPDATE media_items
|
|
SET
|
|
format_group = $2,
|
|
format_mimetype = $3,
|
|
is_reflowable = $4,
|
|
has_fixed_layout = $5,
|
|
total_characters = $6,
|
|
chapter_count = $7,
|
|
updated_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- Bulk update format group for all media items
|
|
-- name: BulkUpdateFormatGroups :exec
|
|
UPDATE media_items m
|
|
SET
|
|
format_group = detect_format_group(m.mime_type, m.file_path),
|
|
format_mimetype = m.mime_type,
|
|
is_reflowable = (detect_format_group(m.mime_type, m.file_path) = 'reflowable'),
|
|
has_fixed_layout = (detect_format_group(m.mime_type, m.file_path) IN ('fixed_layout', 'comic_archive')),
|
|
updated_at = NOW()
|
|
WHERE m.format_group IS NULL OR m.format_group = 'unknown';
|
|
|
|
-- Get universal progress for a book
|
|
-- name: GetUniversalProgress :one
|
|
SELECT
|
|
rp.id,
|
|
rp.media_item_id,
|
|
rp.user_id,
|
|
rp.current_page,
|
|
rp.total_pages,
|
|
rp.last_read_at,
|
|
rp.percentage,
|
|
rp.character_offset,
|
|
rp.epubcfi,
|
|
rp.context_text,
|
|
rp.chapter,
|
|
rp.chapter_progress,
|
|
rp.viewport_x,
|
|
rp.viewport_y,
|
|
rp.zoom_level,
|
|
rp.scroll_position_x,
|
|
rp.scroll_position_y,
|
|
rp.panel_number,
|
|
rp.reading_mode,
|
|
rp.last_sync_device,
|
|
rp.last_sync_source,
|
|
rp.last_sync_timestamp,
|
|
rp.conflict_detected,
|
|
rp.conflict_resolved,
|
|
mi.format_group,
|
|
mi.format_mimetype,
|
|
mi.is_reflowable,
|
|
mi.has_fixed_layout,
|
|
mi.total_characters,
|
|
mi.chapter_count
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON rp.media_item_id = mi.id
|
|
WHERE rp.media_item_id = $1 AND rp.user_id = $2;
|
|
|
|
-- Update universal progress
|
|
-- name: UpdateUniversalProgress :one
|
|
INSERT INTO reading_progress (
|
|
media_item_id,
|
|
user_id,
|
|
percentage,
|
|
character_offset,
|
|
epubcfi,
|
|
context_text,
|
|
chapter,
|
|
chapter_progress,
|
|
viewport_x,
|
|
viewport_y,
|
|
zoom_level,
|
|
scroll_position_x,
|
|
scroll_position_y,
|
|
panel_number,
|
|
reading_mode,
|
|
last_sync_device,
|
|
last_sync_source,
|
|
last_sync_timestamp,
|
|
current_page,
|
|
total_pages,
|
|
last_read_at
|
|
)
|
|
VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, NOW()
|
|
)
|
|
ON CONFLICT (media_item_id, user_id)
|
|
DO UPDATE SET
|
|
percentage = EXCLUDED.percentage,
|
|
character_offset = EXCLUDED.character_offset,
|
|
epubcfi = EXCLUDED.epubcfi,
|
|
context_text = EXCLUDED.context_text,
|
|
chapter = EXCLUDED.chapter,
|
|
chapter_progress = EXCLUDED.chapter_progress,
|
|
viewport_x = EXCLUDED.viewport_x,
|
|
viewport_y = EXCLUDED.viewport_y,
|
|
zoom_level = EXCLUDED.zoom_level,
|
|
scroll_position_x = EXCLUDED.scroll_position_x,
|
|
scroll_position_y = EXCLUDED.scroll_position_y,
|
|
panel_number = EXCLUDED.panel_number,
|
|
reading_mode = EXCLUDED.reading_mode,
|
|
last_sync_device = EXCLUDED.last_sync_device,
|
|
last_sync_source = EXCLUDED.last_sync_source,
|
|
last_sync_timestamp = EXCLUDED.last_sync_timestamp,
|
|
current_page = EXCLUDED.current_page,
|
|
total_pages = EXCLUDED.total_pages,
|
|
last_read_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- Get reading history for a user and book
|
|
-- name: GetReadingHistory :many
|
|
SELECT *
|
|
FROM reading_history
|
|
WHERE user_id = $1 AND media_item_id = $2
|
|
ORDER BY created_at DESC
|
|
LIMIT $3;
|
|
|
|
-- Create reading history entry
|
|
-- name: CreateReadingHistory :one
|
|
INSERT INTO reading_history (
|
|
user_id,
|
|
media_item_id,
|
|
device_id,
|
|
progress_percentage,
|
|
reading_session_start,
|
|
reading_session_end,
|
|
pages_read,
|
|
time_spent_seconds,
|
|
device_metadata
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING *;
|
|
|
|
-- Analytics queries
|
|
|
|
-- Get user reading history for analytics
|
|
-- name: GetUserReadingHistory :many
|
|
SELECT
|
|
rh.id,
|
|
rh.user_id,
|
|
rh.media_item_id,
|
|
rh.device_id,
|
|
rh.progress_percentage,
|
|
rh.reading_session_start,
|
|
rh.reading_session_end,
|
|
rh.pages_read,
|
|
rh.time_spent_seconds,
|
|
rh.device_metadata,
|
|
rh.created_at,
|
|
mi.title,
|
|
mi.author,
|
|
d.device_name,
|
|
d.device_type
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON rh.media_item_id = mi.id
|
|
LEFT JOIN devices d ON rh.device_id = d.id
|
|
WHERE rh.user_id = $1
|
|
AND rh.created_at >= $2
|
|
AND rh.created_at <= $3
|
|
ORDER BY rh.created_at DESC;
|
|
|
|
-- Get user device usage statistics
|
|
-- name: GetUserDeviceUsage :many
|
|
SELECT
|
|
d.id,
|
|
d.device_name,
|
|
d.device_type,
|
|
COUNT(*) as sync_count,
|
|
MAX(rh.created_at) as last_sync,
|
|
SUM(rh.time_spent_seconds) as total_time_seconds
|
|
FROM devices d
|
|
JOIN reading_history rh ON rh.device_id = d.id
|
|
WHERE d.user_id = $1
|
|
GROUP BY d.id, d.device_name, d.device_type
|
|
ORDER BY sync_count DESC;
|
|
|
|
-- Get most popular books for a user
|
|
-- name: GetPopularBooks :many
|
|
SELECT
|
|
mi.id,
|
|
mi.title,
|
|
mi.author,
|
|
COUNT(*) as read_count,
|
|
AVG(rh.progress_percentage) as avg_completion,
|
|
MAX(rh.created_at) as last_read
|
|
FROM media_items mi
|
|
JOIN reading_history rh ON rh.media_item_id = mi.id
|
|
WHERE rh.user_id = $1
|
|
GROUP BY mi.id, mi.title, mi.author
|
|
ORDER BY read_count DESC
|
|
LIMIT $2;
|
|
|
|
-- ============================================
|
|
-- DEVICE MANAGEMENT & AUTH
|
|
-- ============================================
|
|
|
|
-- Device Registration & Management
|
|
-- name: CreateDevice :one
|
|
INSERT INTO devices (user_id, device_name, device_type, device_identifier, auth_token, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING *;
|
|
|
|
-- name: GetDevice :one
|
|
SELECT * FROM devices WHERE id = $1;
|
|
|
|
-- name: GetDeviceByIdentifier :one
|
|
SELECT * FROM devices WHERE device_identifier = $1;
|
|
|
|
-- name: GetDeviceByAuthToken :one
|
|
SELECT * FROM devices WHERE auth_token = $1;
|
|
|
|
-- name: ListDevicesByUser :many
|
|
SELECT * FROM devices WHERE user_id = $1 ORDER BY created_at DESC;
|
|
|
|
-- name: ListDevicesByType :many
|
|
SELECT * FROM devices WHERE device_type = $1 ORDER BY created_at DESC;
|
|
|
|
-- name: UpdateDevice :one
|
|
UPDATE devices
|
|
SET
|
|
device_name = $2,
|
|
sync_enabled = $3,
|
|
auto_sync = $4,
|
|
sync_frequency_minutes = $5,
|
|
device_metadata = $6,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDeviceLastSync :one
|
|
UPDATE devices
|
|
SET
|
|
last_sync = NOW(),
|
|
last_seen = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDeviceLastSeen :one
|
|
UPDATE devices
|
|
SET
|
|
last_seen = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDeviceAuthToken :one
|
|
UPDATE devices
|
|
SET
|
|
auth_token = $2,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: RevokeDevice :exec
|
|
UPDATE devices
|
|
SET
|
|
auth_token = NULL,
|
|
sync_enabled = false,
|
|
updated_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- name: DeleteDevice :exec
|
|
DELETE FROM devices WHERE id = $1;
|
|
|
|
-- name: DeleteDeviceByToken :exec
|
|
DELETE FROM devices WHERE auth_token = $1;
|
|
|
|
-- Sync Queue Management
|
|
-- name: CreateSyncQueueItem :one
|
|
INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, max_attempts, status)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING *;
|
|
|
|
-- name: GetSyncQueueItem :one
|
|
SELECT * FROM sync_queue WHERE id = $1;
|
|
|
|
-- name: ListPendingSyncQueueItems :many
|
|
SELECT * FROM sync_queue
|
|
WHERE device_id = $1 AND status = 'pending'
|
|
ORDER BY priority ASC, created_at ASC
|
|
LIMIT $2;
|
|
|
|
-- name: UpdateSyncQueueItemStatus :one
|
|
UPDATE sync_queue
|
|
SET
|
|
status = $2,
|
|
attempts = attempts + 1,
|
|
error_message = $3,
|
|
processed_at = CASE WHEN $2::varchar = 'completed' THEN NOW() ELSE NULL END
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteSyncQueueItem :exec
|
|
DELETE FROM sync_queue WHERE id = $1;
|
|
|
|
-- name: ClearDeviceSyncQueue :exec
|
|
DELETE FROM sync_queue WHERE device_id = $1;
|
|
|
|
-- name: GetFailedSyncQueueItems :many
|
|
SELECT * FROM sync_queue
|
|
WHERE status = 'failed' AND attempts < max_attempts
|
|
ORDER BY priority ASC, created_at ASC
|
|
LIMIT $1;
|
|
|
|
-- name: GetStuckSyncQueueItems :many
|
|
SELECT * FROM sync_queue
|
|
WHERE status = 'processing' AND created_at < NOW() - INTERVAL '1 hour'
|
|
ORDER BY priority ASC, created_at ASC;
|
|
|
|
-- name: IncrementSyncQueueAttempts :one
|
|
UPDATE sync_queue
|
|
SET
|
|
attempts = attempts + 1,
|
|
status = CASE
|
|
WHEN attempts + 1 >= max_attempts THEN 'failed'
|
|
ELSE 'pending'
|
|
END,
|
|
error_message = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetSyncQueueStats :one
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
|
|
COUNT(*) FILTER (WHERE status = 'processing') as processing_count,
|
|
COUNT(*) FILTER (WHERE status = 'failed') as failed_count,
|
|
COUNT(*) FILTER (WHERE status = 'completed') as completed_count,
|
|
COUNT(*) as total_count
|
|
FROM sync_queue
|
|
WHERE device_id = $1;
|
|
|
|
-- name: ListAllSyncQueueItems :many
|
|
SELECT
|
|
sq.*,
|
|
d.device_name,
|
|
d.device_type,
|
|
u.email as user_email,
|
|
mi.title as media_title
|
|
FROM sync_queue sq
|
|
JOIN devices d ON sq.device_id = d.id
|
|
JOIN users u ON d.user_id = u.id
|
|
LEFT JOIN media_items mi ON sq.media_item_id = mi.id
|
|
ORDER BY sq.priority ASC, sq.created_at DESC
|
|
LIMIT $1 OFFSET $2;
|
|
|
|
-- name: GetNextRetryTime :one
|
|
SELECT
|
|
CASE
|
|
WHEN attempts = 0 THEN NOW()
|
|
WHEN attempts = 1 THEN NOW() + INTERVAL '1 minute'
|
|
WHEN attempts = 2 THEN NOW() + INTERVAL '5 minutes'
|
|
WHEN attempts = 3 THEN NOW() + INTERVAL '15 minutes'
|
|
WHEN attempts = 4 THEN NOW() + INTERVAL '1 hour'
|
|
ELSE NOW() + INTERVAL '24 hours'
|
|
END as next_retry_time;
|
|
|
|
-- Conflict Resolution
|
|
-- name: CreateSyncConflict :one
|
|
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING *;
|
|
|
|
-- name: CreateAutoResolvedSyncConflict :one
|
|
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at)
|
|
VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW())
|
|
RETURNING *;
|
|
|
|
-- name: GetSyncConflict :one
|
|
SELECT * FROM sync_conflicts WHERE id = $1;
|
|
|
|
-- name: ListSyncConflictsByUser :many
|
|
SELECT sc.*, mi.title, mi.author
|
|
FROM sync_conflicts sc
|
|
JOIN media_items mi ON sc.media_item_id = mi.id
|
|
WHERE sc.user_id = $1 AND sc.resolution_status = 'unresolved'
|
|
ORDER BY sc.created_at DESC;
|
|
|
|
-- name: ListSyncConflictsByMediaItem :many
|
|
SELECT * FROM sync_conflicts
|
|
WHERE media_item_id = $1 AND user_id = $2
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: ResolveSyncConflict :one
|
|
UPDATE sync_conflicts
|
|
SET
|
|
resolution_status = $2,
|
|
resolution_data = $3,
|
|
resolved_by = $4,
|
|
resolved_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteSyncConflict :exec
|
|
DELETE FROM sync_conflicts WHERE id = $1;
|
|
|
|
-- name: ListAllConflictsByUserAndStatus :many
|
|
SELECT sc.*, mi.title, mi.author
|
|
FROM sync_conflicts sc
|
|
JOIN media_items mi ON sc.media_item_id = mi.id
|
|
WHERE sc.user_id = $1 AND sc.resolution_status = $2
|
|
ORDER BY sc.created_at DESC;
|
|
|
|
-- name: ListConflictsByUser :many
|
|
SELECT sc.*, mi.title, mi.author
|
|
FROM sync_conflicts sc
|
|
JOIN media_items mi ON sc.media_item_id = mi.id
|
|
WHERE sc.user_id = $1
|
|
ORDER BY sc.created_at DESC;
|
|
|
|
-- name: HasRecentConflictResolution :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM sync_conflicts
|
|
WHERE media_item_id = $1
|
|
AND user_id = $2
|
|
AND resolution_status != 'unresolved'
|
|
AND resolved_at > NOW() - INTERVAL '10 minutes'
|
|
);
|
|
|
|
-- ============================================
|
|
-- KOREADER SYNC PROTOCOL
|
|
-- ============================================
|
|
|
|
-- name: GetMediaItemByFilePathForSync :one
|
|
SELECT * FROM media_items WHERE file_path = $1;
|
|
|
|
-- name: GetUserProgressForBooks :many
|
|
SELECT
|
|
rp.media_item_id,
|
|
rp.user_id,
|
|
rp.percentage,
|
|
rp.character_offset,
|
|
rp.epubcfi,
|
|
rp.chapter,
|
|
rp.chapter_progress,
|
|
rp.current_page,
|
|
rp.total_pages,
|
|
rp.last_read_at,
|
|
rp.last_sync_device,
|
|
rp.last_sync_source,
|
|
mi.title,
|
|
mi.author,
|
|
mi.file_path
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON rp.media_item_id = mi.id
|
|
WHERE rp.user_id = $1
|
|
AND rp.media_item_id = ANY($2::uuid[])
|
|
ORDER BY rp.last_read_at DESC;
|
|
|
|
-- name: BulkUpdateProgressFromSync :many
|
|
SELECT * FROM bulk_update_progress_from_koreader($1::uuid, $2::jsonb);
|
|
|
|
-- name: GetAnnotationsForBook :many
|
|
SELECT
|
|
mh.id,
|
|
mh.selection_text,
|
|
mh.start_position,
|
|
mh.end_position,
|
|
mh.color,
|
|
mh.created_at,
|
|
mh.updated_at,
|
|
'highlight' as annotation_type,
|
|
mh.percentage_start,
|
|
mh.percentage_end,
|
|
mh.epubcfi_start,
|
|
mh.epubcfi_end
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.content,
|
|
mn.position,
|
|
NULL as end_position,
|
|
NULL as color,
|
|
mn.created_at,
|
|
mn.updated_at,
|
|
'note' as annotation_type,
|
|
mn.percentage_location as percentage_start,
|
|
NULL as percentage_end,
|
|
mn.epubcfi_location as epubcfi_start,
|
|
NULL as epubcfi_end
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: UpdateDeviceSyncTimestamp :one
|
|
UPDATE devices
|
|
SET
|
|
last_sync = NOW(),
|
|
last_seen = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: CreateSyncHistoryEntry :one
|
|
INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, status)
|
|
VALUES ($1, $2, 'koreader_progress', $3, 5, 'completed')
|
|
RETURNING *;
|
|
|
|
-- name: GetUserMediaItemsForSync :many
|
|
SELECT
|
|
mi.id,
|
|
mi.title,
|
|
mi.author,
|
|
mi.file_path,
|
|
mi.mime_type,
|
|
mi.page_count,
|
|
mi.format_group,
|
|
mi.total_characters,
|
|
mi.chapter_count,
|
|
mi.entitlement_id,
|
|
mi.revision_number,
|
|
mi.file_size,
|
|
mi.file_sha256
|
|
FROM media_items mi
|
|
JOIN library_visibility lv ON mi.library_id = lv.library_id
|
|
WHERE lv.user_id = $1
|
|
AND lv.is_visible = true
|
|
ORDER BY mi.title ASC
|
|
LIMIT 1000;
|
|
|
|
-- name: CheckForProgressConflicts :one
|
|
SELECT COUNT(*) as conflict_count
|
|
FROM reading_progress
|
|
WHERE media_item_id = $1
|
|
AND user_id = $2
|
|
AND last_sync_timestamp > NOW() - INTERVAL '5 minutes'
|
|
AND last_sync_source != $3;
|
|
|
|
-- ============================================
|
|
-- KOBO SHELF MANAGEMENT QUERIES
|
|
-- ============================================
|
|
|
|
-- name: AddBookToKoboShelf :one
|
|
INSERT INTO kobo_shelves (device_id, media_item_id, shelf_name, shelf_position)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (device_id, media_item_id)
|
|
DO UPDATE SET
|
|
shelf_name = EXCLUDED.shelf_name,
|
|
shelf_position = EXCLUDED.shelf_position,
|
|
last_synced_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: RemoveBookFromKoboShelf :exec
|
|
DELETE FROM kobo_shelves WHERE device_id = $1 AND media_item_id = $2;
|
|
|
|
-- name: GetKoboShelfBooks :many
|
|
SELECT ks.*, mi.title, mi.author, mi.file_path, mi.mime_type, mi.entitlement_id, mi.kobo_content_id, mi.revision_number
|
|
FROM kobo_shelves ks
|
|
JOIN media_items mi ON ks.media_item_id = mi.id
|
|
WHERE ks.device_id = $1
|
|
ORDER BY ks.shelf_position ASC, ks.added_at ASC;
|
|
|
|
-- name: GetKoboShelfBooksByShelfName :many
|
|
SELECT ks.*, mi.title, mi.author, mi.file_path, mi.mime_type, mi.entitlement_id, mi.kobo_content_id, mi.revision_number
|
|
FROM kobo_shelves ks
|
|
JOIN media_items mi ON ks.media_item_id = mi.id
|
|
WHERE ks.device_id = $1 AND ks.shelf_name = $2
|
|
ORDER BY ks.shelf_position ASC, ks.added_at ASC;
|
|
|
|
-- name: UpdateKoboShelfBookPosition :exec
|
|
UPDATE kobo_shelves
|
|
SET shelf_position = $3, last_synced_at = NOW()
|
|
WHERE device_id = $1 AND media_item_id = $2;
|
|
|
|
-- name: ClearKoboShelf :exec
|
|
DELETE FROM kobo_shelves WHERE device_id = $1;
|
|
|
|
-- name: ClearKoboShelfByName :exec
|
|
DELETE FROM kobo_shelves WHERE device_id = $1 AND shelf_name = $2;
|
|
|
|
-- name: IsBookOnKoboShelf :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM kobo_shelves
|
|
WHERE device_id = $1 AND media_item_id = $2
|
|
) as on_shelf;
|
|
|
|
-- name: GetKoboShelfBookCount :one
|
|
SELECT COUNT(*) as book_count
|
|
FROM kobo_shelves
|
|
WHERE device_id = $1;
|
|
|
|
-- ============================================
|
|
-- KOBO ENTITLEMENT QUERIES
|
|
-- ============================================
|
|
|
|
-- name: CreateOrUpdateKoboEntitlement :one
|
|
INSERT INTO kobo_entitlements (device_id, media_item_id, entitlement_id, content_id, revision_number, purchase_date, kobo_metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (device_id, entitlement_id)
|
|
DO UPDATE SET
|
|
content_id = EXCLUDED.content_id,
|
|
revision_number = EXCLUDED.revision_number,
|
|
purchase_date = COALESCE(EXCLUDED.purchase_date, kobo_entitlements.purchase_date),
|
|
book_status = 'installed',
|
|
sync_status = 'synced',
|
|
kobo_metadata = EXCLUDED.kobo_metadata,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetKoboEntitlementsForDevice :many
|
|
SELECT ke.*, mi.title, mi.author, mi.file_path
|
|
FROM kobo_entitlements ke
|
|
JOIN media_items mi ON ke.media_item_id = mi.id
|
|
WHERE ke.device_id = $1
|
|
ORDER BY ke.accession_date DESC;
|
|
|
|
-- name: GetKoboEntitlementByContentId :one
|
|
SELECT ke.*, mi.title, mi.author, mi.file_path
|
|
FROM kobo_entitlements ke
|
|
JOIN media_items mi ON ke.media_item_id = mi.id
|
|
WHERE ke.device_id = $1 AND ke.content_id = $2;
|
|
|
|
-- name: GetKoboEntitlementByEntitlementId :one
|
|
SELECT ke.*, mi.title, mi.author, mi.file_path
|
|
FROM kobo_entitlements ke
|
|
JOIN media_items mi ON ke.media_item_id = mi.id
|
|
WHERE ke.device_id = $1 AND ke.entitlement_id = $2;
|
|
|
|
-- name: UpdateKoboEntitlementStatus :exec
|
|
UPDATE kobo_entitlements
|
|
SET book_status = $3, sync_status = 'synced', updated_at = NOW()
|
|
WHERE device_id = $1 AND entitlement_id = $2;
|
|
|
|
-- name: UpdateKoboEntitlementRevision :exec
|
|
UPDATE kobo_entitlements
|
|
SET revision_number = $3, updated_at = NOW()
|
|
WHERE device_id = $1 AND entitlement_id = $2;
|
|
|
|
-- name: DeleteKoboEntitlement :exec
|
|
DELETE FROM kobo_entitlements WHERE device_id = $1 AND entitlement_id = $2;
|
|
|
|
-- name: GenerateKoboEntitlementId :one
|
|
SELECT 'kobo_' || uuid_generate_v4()::TEXT as entitlement_id;
|
|
|
|
-- name: UpdateMediaItemKoboMetadata :one
|
|
UPDATE media_items
|
|
SET entitlement_id = $2,
|
|
kobo_content_id = $3,
|
|
revision_number = COALESCE($4, revision_number) + 1,
|
|
kobo_metadata = $5,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaItemByKoboContentId :one
|
|
SELECT * FROM media_items WHERE kobo_content_id = $1;
|
|
|
|
-- Media Items Admin Operations
|
|
|
|
-- ============================================
|
|
-- UNIVERSAL BOOK IDENTIFIERS
|
|
-- ============================================
|
|
|
|
-- Update media item with universal identifiers
|
|
-- name: UpdateMediaItemIdentifiers :one
|
|
UPDATE media_items
|
|
SET
|
|
file_sha256 = $2,
|
|
opf_identifier = $3,
|
|
opf_uuid = $4,
|
|
hash_confidence = $5,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Get media item by SHA-256 hash
|
|
-- name: GetMediaItemBySHA256 :one
|
|
SELECT * FROM media_items WHERE file_sha256 = $1;
|
|
|
|
-- Get media item by SHA-256 hash within a specific library (content dedup)
|
|
-- name: GetMediaItemBySHA256AndLibrary :one
|
|
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2;
|
|
|
|
-- List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
|
-- name: ListMediaItemsBySHA256AndLibrary :many
|
|
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path;
|
|
|
|
-- List media items that have no stored SHA-256 (imported before hashing existed)
|
|
-- name: ListMediaItemsMissingHash :many
|
|
SELECT * FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at;
|
|
|
|
-- Find content-duplicate groups (same library + SHA-256, more than one row)
|
|
-- name: FindHashConflictGroups :many
|
|
SELECT library_id, file_sha256, COUNT(*) AS dup_count
|
|
FROM media_items
|
|
WHERE file_sha256 IS NOT NULL
|
|
GROUP BY library_id, file_sha256
|
|
HAVING COUNT(*) > 1;
|
|
|
|
-- Per-item user-data counts, used when choosing which duplicate copy to keep
|
|
-- name: GetMediaItemUsageCounts :one
|
|
SELECT
|
|
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = $1) AS progress_count,
|
|
(SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = $1) AS highlights_count,
|
|
(SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = $1) AS bookmarks_count,
|
|
(SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = $1) AS notes_count,
|
|
(SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = $1) AS collections_count;
|
|
|
|
-- HASH CONFLICTS QUERIES
|
|
|
|
-- Record a pending hash conflict (no-op if the group is already tracked, so
|
|
-- resolved groups stay resolved and are never re-flagged)
|
|
-- name: CreateHashConflict :exec
|
|
INSERT INTO hash_conflicts (library_id, file_sha256)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (library_id, file_sha256) DO NOTHING;
|
|
|
|
-- name: ListPendingHashConflicts :many
|
|
SELECT hc.id, hc.library_id, hc.file_sha256, hc.created_at,
|
|
l.name AS library_name,
|
|
COUNT(mi.id) AS item_count
|
|
FROM hash_conflicts hc
|
|
JOIN libraries l ON l.id = hc.library_id
|
|
LEFT JOIN media_items mi ON mi.library_id = hc.library_id AND mi.file_sha256 = hc.file_sha256
|
|
WHERE hc.status = 'pending'
|
|
GROUP BY hc.id, hc.library_id, hc.file_sha256, hc.created_at, l.name
|
|
ORDER BY hc.created_at;
|
|
|
|
-- name: GetHashConflict :one
|
|
SELECT * FROM hash_conflicts WHERE id = $1;
|
|
|
|
-- name: ResolveHashConflict :exec
|
|
UPDATE hash_conflicts
|
|
SET status = 'resolved',
|
|
resolution = $2,
|
|
resolved_by = $3,
|
|
resolved_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
|
-- name: ReparentMediaItemChildren :exec
|
|
SELECT reparent_media_item_children($1::uuid, $2::uuid);
|
|
|
|
-- Get media item by OPF identifier
|
|
-- name: GetMediaItemByOPFIdentifier :one
|
|
SELECT * FROM media_items WHERE opf_identifier = $1;
|
|
|
|
-- Get media item by OPF UUID
|
|
-- name: GetMediaItemByOPFUUID :one
|
|
SELECT * FROM media_items WHERE opf_uuid = $1;
|
|
|
|
-- Query media items by multiple identifiers with confidence scoring
|
|
-- name: QueryMediaItemsByIdentifiers :many
|
|
SELECT
|
|
mi.id,
|
|
mi.file_sha256,
|
|
mi.opf_identifier,
|
|
mi.opf_uuid,
|
|
mi.isbn,
|
|
mi.asin,
|
|
mi.title,
|
|
mi.author,
|
|
mi.file_size,
|
|
mi.hash_confidence,
|
|
CASE
|
|
WHEN $1::uuid IS NOT NULL AND mi.id = $1::uuid THEN 1.0
|
|
WHEN mi.opf_uuid IS NOT NULL AND mi.opf_uuid = $2::text THEN 0.95
|
|
WHEN mi.file_sha256 IS NOT NULL AND mi.file_sha256 = $3::text THEN 0.9
|
|
WHEN mi.opf_identifier IS NOT NULL AND mi.opf_identifier = $4::text THEN 0.85
|
|
WHEN mi.isbn IS NOT NULL AND mi.isbn = $5::text THEN 0.8
|
|
WHEN mi.asin IS NOT NULL AND mi.asin = $6::text THEN 0.8
|
|
ELSE 0.5
|
|
END as confidence_score
|
|
FROM media_items mi
|
|
WHERE
|
|
($1::uuid IS NULL OR mi.id = $1::uuid)
|
|
OR ($2::text IS NULL OR mi.opf_uuid = $2::text)
|
|
OR ($3::text IS NULL OR mi.file_sha256 = $3::text)
|
|
OR ($4::text IS NULL OR mi.opf_identifier = $4::text)
|
|
OR ($5::text IS NULL OR mi.isbn = $5::text)
|
|
OR ($6::text IS NULL OR mi.asin = $6::text)
|
|
OR ($7::text IS NULL OR mi.title ILIKE '%' || $7::text || '%')
|
|
ORDER BY confidence_score DESC;
|
|
|
|
-- MEDIA ITEM FORMATS QUERIES
|
|
|
|
-- Create media item format
|
|
-- name: CreateMediaItemFormat :one
|
|
INSERT INTO media_item_formats (media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, converted_from_format_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (media_item_id, format_type) DO UPDATE SET
|
|
file_path = EXCLUDED.file_path,
|
|
file_sha256 = EXCLUDED.file_sha256,
|
|
file_size_bytes = EXCLUDED.file_size_bytes,
|
|
mime_type = EXCLUDED.mime_type
|
|
RETURNING *;
|
|
|
|
-- Get media item formats
|
|
-- name: GetMediaItemFormats :many
|
|
SELECT * FROM media_item_formats WHERE media_item_id = $1;
|
|
|
|
-- Get media item format by type
|
|
-- name: GetMediaItemFormatByType :one
|
|
SELECT * FROM media_item_formats WHERE media_item_id = $1 AND format_type = $2;
|
|
|
|
-- Get media item format by SHA-256
|
|
-- name: GetMediaItemFormatBySHA256 :one
|
|
SELECT * FROM media_item_formats WHERE file_sha256 = $1;
|
|
|
|
-- Update media item format
|
|
-- name: UpdateMediaItemFormat :one
|
|
UPDATE media_item_formats
|
|
SET
|
|
file_path = $2,
|
|
file_sha256 = $3,
|
|
file_size_bytes = $4,
|
|
mime_type = $5
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Delete media item format
|
|
-- name: DeleteMediaItemFormat :exec
|
|
DELETE FROM media_item_formats WHERE id = $1;
|
|
|
|
-- DEVICE FILE ALIASES QUERIES
|
|
|
|
-- Create device file alias
|
|
-- name: CreateDeviceFileAlias :one
|
|
INSERT INTO device_file_aliases (media_item_id, device_id, file_path, file_sha256, confidence_score)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *;
|
|
|
|
-- Get device file alias
|
|
-- name: GetDeviceFileAlias :one
|
|
SELECT * FROM device_file_aliases WHERE device_id = $1 AND file_path = $2;
|
|
|
|
-- Get device file aliases by device
|
|
-- name: GetDeviceFileAliasesByDevice :many
|
|
SELECT dfa.*, mi.title, mi.author
|
|
FROM device_file_aliases dfa
|
|
JOIN media_items mi ON dfa.media_item_id = mi.id
|
|
WHERE dfa.device_id = $1
|
|
ORDER BY dfa.last_seen_at DESC;
|
|
|
|
-- Get device file alias by SHA-256
|
|
-- name: GetDeviceFileAliasBySHA256 :one
|
|
SELECT dfa.*, mi.title, mi.author
|
|
FROM device_file_aliases dfa
|
|
JOIN media_items mi ON dfa.media_item_id = mi.id
|
|
WHERE dfa.file_sha256 = $1;
|
|
|
|
-- Update device file alias
|
|
-- name: UpdateDeviceFileAlias :one
|
|
UPDATE device_file_aliases
|
|
SET
|
|
media_item_id = $2,
|
|
file_sha256 = $3,
|
|
confidence_score = $4,
|
|
last_seen_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Delete device file alias
|
|
-- name: DeleteDeviceFileAlias :exec
|
|
DELETE FROM device_file_aliases WHERE id = $1;
|
|
|
|
-- COLLECTIONS QUERIES
|
|
|
|
-- Create collection
|
|
-- name: CreateCollection :one
|
|
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING *;
|
|
|
|
-- Get collection
|
|
-- name: GetCollection :one
|
|
SELECT * FROM collections WHERE id = $1;
|
|
|
|
-- Get collections by user
|
|
-- name: GetCollectionsByUser :many
|
|
SELECT * FROM collections WHERE user_id = $1 ORDER BY created_at DESC;
|
|
|
|
-- Get collection with book count
|
|
-- name: GetCollectionWithBookCount :one
|
|
SELECT
|
|
c.*,
|
|
COUNT(ci.id) as book_count
|
|
FROM collections c
|
|
LEFT JOIN collection_items ci ON c.id = ci.collection_id
|
|
WHERE c.id = $1
|
|
GROUP BY c.id;
|
|
|
|
-- Update collection
|
|
-- name: UpdateCollection :one
|
|
UPDATE collections
|
|
SET
|
|
name = $2,
|
|
description = $3,
|
|
color = $4,
|
|
icon = $5,
|
|
auto_assign_rules = $6,
|
|
view_settings = $7
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Delete collection
|
|
-- name: DeleteCollection :exec
|
|
DELETE FROM collections WHERE id = $1;
|
|
|
|
-- COLLECTION ITEMS QUERIES
|
|
|
|
-- Add book to collection
|
|
-- name: AddBookToCollection :one
|
|
INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
|
RETURNING *;
|
|
|
|
-- Remove book from collection
|
|
-- name: RemoveBookFromCollection :exec
|
|
DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2;
|
|
|
|
-- Get collection items
|
|
-- name: GetCollectionItems :many
|
|
SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id
|
|
FROM collection_items ci
|
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
|
WHERE ci.collection_id = $1
|
|
ORDER BY ci.added_at DESC;
|
|
|
|
-- Get collections for book
|
|
-- name: GetCollectionsForBook :many
|
|
SELECT c.*
|
|
FROM collections c
|
|
JOIN collection_items ci ON c.id = ci.collection_id
|
|
WHERE ci.media_item_id = $1;
|
|
|
|
-- Check if book is in collection
|
|
-- name: IsBookInCollection :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM collection_items
|
|
WHERE collection_id = $1 AND media_item_id = $2
|
|
) as in_collection;
|
|
|
|
-- DEVICE SHELF MAPPINGS QUERIES
|
|
|
|
-- Create device shelf mapping
|
|
-- name: CreateDeviceShelfMapping :one
|
|
INSERT INTO device_shelf_mappings (collection_id, device_id, device_shelf_name, sync_direction)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (collection_id, device_id)
|
|
DO UPDATE SET
|
|
device_shelf_name = EXCLUDED.device_shelf_name,
|
|
sync_direction = EXCLUDED.sync_direction
|
|
RETURNING *;
|
|
|
|
-- Get device shelf mappings
|
|
-- name: GetDeviceShelfMappings :many
|
|
SELECT dsm.*, c.name as collection_name, c.icon as collection_icon
|
|
FROM device_shelf_mappings dsm
|
|
JOIN collections c ON dsm.collection_id = c.id
|
|
WHERE dsm.device_id = $1;
|
|
|
|
-- Get device shelf mapping
|
|
-- name: GetDeviceShelfMapping :one
|
|
SELECT * FROM device_shelf_mappings WHERE device_id = $1 AND collection_id = $2;
|
|
|
|
-- Update device shelf mapping
|
|
-- name: UpdateDeviceShelfMapping :one
|
|
UPDATE device_shelf_mappings
|
|
SET
|
|
device_shelf_name = $2,
|
|
sync_direction = $3
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Delete device shelf mapping
|
|
-- name: DeleteDeviceShelfMapping :exec
|
|
DELETE FROM device_shelf_mappings WHERE id = $1;
|
|
|
|
-- DEVICE CATALOGS QUERIES
|
|
|
|
-- Create device catalog entry
|
|
-- name: CreateDeviceCatalog :one
|
|
INSERT INTO device_catalogs (device_id, media_item_id, bookhoard_uuid, kobo_content_id, content_id_type, available, delivery_date, delivery_method)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (device_id, kobo_content_id)
|
|
DO UPDATE SET
|
|
available = EXCLUDED.available,
|
|
delivery_date = COALESCE(EXCLUDED.delivery_date, device_catalogs.delivery_date),
|
|
delivery_method = EXCLUDED.delivery_method
|
|
RETURNING *;
|
|
|
|
-- Get device catalog by Bookhoard UUID
|
|
-- name: GetDeviceCatalogByBookhoardUUID :one
|
|
SELECT * FROM device_catalogs WHERE device_id = $1 AND bookhoard_uuid = $2;
|
|
|
|
-- Get device catalog by Kobo ContentId
|
|
-- name: GetDeviceCatalogByKoboContentId :one
|
|
SELECT * FROM device_catalogs WHERE kobo_content_id = $1;
|
|
|
|
-- Get device catalog entries
|
|
-- name: GetDeviceCatalogEntries :many
|
|
SELECT dc.*, mi.title, mi.author
|
|
FROM device_catalogs dc
|
|
JOIN media_items mi ON dc.media_item_id = mi.id
|
|
WHERE dc.device_id = $1 AND dc.available = true
|
|
ORDER BY dc.delivery_date DESC;
|
|
|
|
-- Update device catalog availability
|
|
-- name: UpdateDeviceCatalogAvailability :exec
|
|
UPDATE device_catalogs
|
|
SET available = $2
|
|
WHERE id = $1;
|
|
|
|
-- Delete device catalog entry
|
|
-- name: DeleteDeviceCatalog :exec
|
|
DELETE FROM device_catalogs WHERE id = $1;
|
|
|
|
-- SYSTEM CONFIG QUERIES
|
|
|
|
-- Get system config
|
|
-- name: GetSystemConfig :one
|
|
SELECT * FROM system_config WHERE key = $1;
|
|
|
|
-- Get all system config
|
|
-- name: GetAllSystemConfig :many
|
|
SELECT * FROM system_config ORDER BY key;
|
|
|
|
-- Set system config
|
|
-- name: SetSystemConfig :one
|
|
INSERT INTO system_config (key, value, updated_by)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (key)
|
|
DO UPDATE SET
|
|
value = EXCLUDED.value,
|
|
updated_by = EXCLUDED.updated_by,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- Delete system config
|
|
-- name: DeleteSystemConfig :exec
|
|
DELETE FROM system_config WHERE key = $1;
|
|
|
|
-- OPDS TOKENS QUERIES
|
|
|
|
-- Create OPDS token
|
|
-- name: CreateOpdsToken :one
|
|
INSERT INTO opds_tokens (device_id, token, token_type, expires_at)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING *;
|
|
|
|
-- Get OPDS token
|
|
-- name: GetOpdsToken :one
|
|
SELECT ot.*, d.device_name, d.device_type
|
|
FROM opds_tokens ot
|
|
JOIN devices d ON ot.device_id = d.id
|
|
WHERE ot.token = $1 AND ot.expires_at > NOW();
|
|
|
|
-- Get OPDS tokens by device
|
|
-- name: GetOpdsTokensByDevice :many
|
|
SELECT * FROM opds_tokens WHERE device_id = $1 AND expires_at > NOW();
|
|
|
|
-- Revoke OPDS token
|
|
-- name: RevokeOpdsToken :exec
|
|
DELETE FROM opds_tokens WHERE token = $1;
|
|
|
|
-- Cleanup expired OPDS tokens
|
|
-- name: CleanupExpiredOpdsTokens :exec
|
|
DELETE FROM opds_tokens WHERE expires_at < NOW();
|
|
|
|
-- Update Kobo shelves to support collections
|
|
-- name: UpdateKoboShelfCollection :one
|
|
UPDATE kobo_shelves
|
|
SET
|
|
collection_id = $2,
|
|
position_in_collection = $3,
|
|
last_synced_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Get Kobo shelves by collection
|
|
-- name: GetKoboShelvesByCollection :many
|
|
SELECT ks.*, mi.title, mi.author
|
|
FROM kobo_shelves ks
|
|
JOIN media_items mi ON ks.media_item_id = mi.id
|
|
WHERE ks.device_id = $1 AND ks.collection_id = $2
|
|
ORDER BY ks.position_in_collection ASC, ks.shelf_position ASC;
|
|
|
|
-- ============================================
|
|
-- ENHANCED KOBO SYNC
|
|
-- ============================================
|
|
|
|
-- Create unlinked book entry
|
|
-- name: CreateUnlinkedBook :one
|
|
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *;
|
|
|
|
-- Get unlinked books for a device
|
|
-- name: GetUnlinkedBooksByDevice :many
|
|
SELECT ub.*, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE ub.device_id = $1 AND ub.resolved = false
|
|
ORDER BY ub.last_seen_at DESC;
|
|
|
|
-- Get unlinked book by ContentId
|
|
-- name: GetUnlinkedBookByContentId :one
|
|
SELECT * FROM unlinked_books WHERE device_id = $1 AND content_id = $2;
|
|
|
|
-- Resolve unlinked book
|
|
-- name: ResolveUnlinkedBook :one
|
|
UPDATE unlinked_books
|
|
SET
|
|
resolved = true,
|
|
media_item_id = $2,
|
|
resolved_at = NOW(),
|
|
resolution_method = $3
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Count unlinked books for a device
|
|
-- name: CountUnlinkedBooks :one
|
|
SELECT COUNT(*) as count
|
|
FROM unlinked_books
|
|
WHERE device_id = $1 AND resolved = false;
|
|
|
|
-- Link unlinked book to media item
|
|
-- name: LinkUnlinkedBook :one
|
|
UPDATE unlinked_books
|
|
SET
|
|
media_item_id = $2,
|
|
confidence_score = $3,
|
|
resolved = true,
|
|
resolved_at = NOW(),
|
|
resolution_method = 'manual_link'
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Get unlinked book by ID
|
|
-- name: GetUnlinkedBookByID :one
|
|
SELECT * FROM unlinked_books WHERE id = $1;
|
|
|
|
-- Delete unlinked book
|
|
-- name: DeleteUnlinkedBook :exec
|
|
DELETE FROM unlinked_books WHERE id = $1;
|
|
|
|
-- List unresolved unlinked books with pagination
|
|
-- name: ListUnresolvedUnlinkedBooks :many
|
|
SELECT ub.*, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE ub.resolved = false
|
|
ORDER BY ub.last_seen_at DESC
|
|
LIMIT $1 OFFSET $2;
|
|
|
|
-- ============================================
|
|
-- CAROUSEL-STYLE DASHBOARD
|
|
-- ============================================
|
|
|
|
-- Dashboard preferences queries
|
|
-- name: GetDashboardPreferences :one
|
|
SELECT * FROM user_dashboard_preferences
|
|
WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid);
|
|
|
|
-- name: UpsertDashboardPreferences :one
|
|
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (user_id, library_id)
|
|
DO UPDATE SET
|
|
hidden_collections = EXCLUDED.hidden_collections,
|
|
collection_order = EXCLUDED.collection_order,
|
|
items_per_section = EXCLUDED.items_per_section,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDashboardPreferences :one
|
|
UPDATE user_dashboard_preferences
|
|
SET hidden_collections = $2,
|
|
collection_order = $3,
|
|
items_per_section = $4,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND library_id = $5
|
|
RETURNING *;
|
|
|
|
-- Dashboard collections queries
|
|
-- name: GetSystemCollectionsForDashboard :many
|
|
SELECT * FROM collections
|
|
WHERE user_id = $1
|
|
AND is_system_collection = true
|
|
AND show_on_dashboard = true
|
|
ORDER BY priority ASC;
|
|
|
|
-- name: GetUserCollectionsForDashboard :many
|
|
SELECT c.* FROM collections c
|
|
WHERE c.user_id = $1
|
|
AND c.show_on_dashboard = true
|
|
AND c.is_system_collection = false
|
|
ORDER BY priority ASC;
|
|
|
|
-- name: DeleteUserSystemCollection :exec
|
|
DELETE FROM collections
|
|
WHERE user_id = $1
|
|
AND name = $2
|
|
AND is_system_collection = true;
|
|
|
|
-- name: CreateSystemCollection :one
|
|
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
|
|
RETURNING *;
|
|
|
|
-- name: ResetSystemCollectionMetadata :exec
|
|
UPDATE collections
|
|
SET description = $3,
|
|
icon = $4,
|
|
color = $5,
|
|
priority = $6
|
|
WHERE user_id = $1
|
|
AND name = $2
|
|
AND is_system_collection = true;
|
|
|
|
-- Smart section queries (for system collections)
|
|
-- name: GetContinueReadingItems :many
|
|
SELECT mi.* FROM media_items mi
|
|
INNER JOIN (
|
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
|
FROM reading_progress
|
|
WHERE user_id = sqlc.narg('user_id')
|
|
AND percentage > 0
|
|
AND percentage < 1
|
|
ORDER BY media_item_id, last_read_at DESC
|
|
) rp ON rp.media_item_id = mi.id
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
ORDER BY rp.last_read_at DESC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetRecentlyAddedItems :many
|
|
SELECT mi.* FROM media_items mi
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetRecentlyReadItems :many
|
|
SELECT mi.* FROM media_items mi
|
|
INNER JOIN (
|
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
|
FROM reading_progress
|
|
WHERE user_id = sqlc.narg('user_id')
|
|
AND percentage >= 1
|
|
ORDER BY media_item_id, last_read_at DESC
|
|
) rp ON rp.media_item_id = mi.id
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
ORDER BY rp.last_read_at DESC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetNotStartedItems :many
|
|
SELECT mi.* FROM media_items mi
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM reading_progress rp
|
|
WHERE rp.media_item_id = mi.id
|
|
AND rp.user_id = sqlc.narg('user_id')
|
|
AND rp.percentage > 0
|
|
)
|
|
ORDER BY mi.created_at DESC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetCollectionItemsForDashboard :many
|
|
SELECT mi.*, ci.excluded FROM media_items mi
|
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
|
WHERE ci.collection_id = sqlc.narg('collection_id')
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
ORDER BY ci.added_at DESC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetLibraryItems :many
|
|
SELECT mi.* FROM media_items mi
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
ORDER BY mi.created_at DESC;
|
|
|
|
-- name: GetDistinctSeries :many
|
|
SELECT series, COUNT(*) as book_count,
|
|
MAX(series_count) as total_in_series,
|
|
MAX(created_at) as last_entry_at
|
|
FROM media_items
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != ''
|
|
GROUP BY series
|
|
ORDER BY MAX(created_at) DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: GetDistinctSeriesCount :one
|
|
SELECT COUNT(DISTINCT series)::int
|
|
FROM media_items
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != '';
|
|
|
|
-- name: GetSeriesCovers :many
|
|
SELECT cover_image_path, library_id
|
|
FROM media_items
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series = sqlc.narg('series') AND cover_image_path IS NOT NULL AND cover_image_path != ''
|
|
ORDER BY series_number ASC NULLS LAST
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetSeriesBooks :many
|
|
SELECT * FROM media_items
|
|
WHERE series = sqlc.narg('series')
|
|
ORDER BY series_number ASC NULLS LAST;
|
|
|
|
-- name: GetContinueSeriesItems :many
|
|
WITH user_series_progress AS (
|
|
SELECT mi.series,
|
|
MAX(mi.series_number) as max_read_number,
|
|
MAX(rp.last_read_at) as last_read_at
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
|
WHERE rp.user_id = sqlc.narg('user_id')
|
|
AND rp.percentage > 0
|
|
AND mi.series IS NOT NULL AND mi.series != ''
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
GROUP BY mi.series
|
|
),
|
|
next_books AS (
|
|
SELECT DISTINCT ON (mi.series) mi.*,
|
|
usp.last_read_at
|
|
FROM media_items mi
|
|
JOIN user_series_progress usp ON mi.series = usp.series
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
|
|
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
|
)
|
|
SELECT * FROM next_books
|
|
ORDER BY last_read_at DESC NULLS LAST
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetSavedFilters :many
|
|
SELECT * FROM saved_filters
|
|
WHERE user_id = @user_id AND resource_type = @resource_type
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: GetSavedFilterByID :one
|
|
SELECT * FROM saved_filters
|
|
WHERE id = @id AND user_id = @user_id;
|
|
|
|
-- name: CreateSavedFilter :one
|
|
INSERT INTO saved_filters (user_id, name, resource_type, filters)
|
|
VALUES (@user_id, @name, @resource_type, @filters)
|
|
RETURNING *;
|
|
|
|
-- name: UpdateSavedFilter :one
|
|
UPDATE saved_filters
|
|
SET name = @name,
|
|
filters = @filters,
|
|
updated_at = NOW()
|
|
WHERE id = @id AND user_id = @user_id
|
|
RETURNING *;
|
|
|
|
-- name: DeleteSavedFilter :one
|
|
DELETE FROM saved_filters
|
|
WHERE id = @id AND user_id = @user_id
|
|
RETURNING *;
|
|
|
|
-- name: GetPanelData :one
|
|
SELECT * FROM panel_data
|
|
WHERE media_item_id = $1 AND page_number = $2;
|
|
|
|
-- name: UpsertPanelData :one
|
|
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (media_item_id, page_number)
|
|
DO UPDATE SET
|
|
detection_method = EXCLUDED.detection_method,
|
|
panels = EXCLUDED.panels,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: UpdateMediaItemChapterMetadata :one
|
|
UPDATE media_items
|
|
SET chapter_metadata = $2, updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetReadingSpeed :one
|
|
SELECT * FROM reading_speed
|
|
WHERE user_id = $1 AND media_item_id = $2;
|
|
|
|
-- name: CreateReadingSpeed :one
|
|
INSERT INTO reading_speed (user_id, media_item_id, pages_per_minute, pages_read, total_reading_minutes, last_read_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *;
|
|
|
|
-- name: UpdateReadingSpeed :one
|
|
UPDATE reading_speed
|
|
SET
|
|
pages_per_minute = $3,
|
|
pages_read = pages_read + $4,
|
|
total_reading_minutes = total_reading_minutes + $5,
|
|
last_read_at = $6,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2
|
|
RETURNING *;
|
|
|
|
-- name: GetDictionaryEntry :one
|
|
SELECT * FROM dictionary_cache
|
|
WHERE word = $1;
|
|
|
|
-- name: CreateDictionaryEntry :one
|
|
INSERT INTO dictionary_cache (word, definition, part_of_speech, example, etymology, accessed_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDictionaryAccessed :one
|
|
UPDATE dictionary_cache
|
|
SET accessed_at = NOW()
|
|
WHERE word = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetReaderSettings :one
|
|
SELECT setting_value FROM reader_settings
|
|
WHERE user_id = $1 AND setting_key = 'reader_settings';
|
|
|
|
-- name: UpsertReaderSettings :one
|
|
INSERT INTO reader_settings (user_id, setting_key, setting_value)
|
|
VALUES ($1, 'reader_settings', $2)
|
|
ON CONFLICT (user_id, setting_key)
|
|
DO UPDATE SET
|
|
setting_value = EXCLUDED.setting_value,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaBookmarks :many
|
|
SELECT * FROM media_bookmarks
|
|
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: GetMediaBookmark :one
|
|
SELECT * FROM media_bookmarks WHERE id = $1;
|
|
|
|
-- name: CreateMediaBookmark :one
|
|
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING *;
|
|
|
|
-- name: DeleteMediaBookmark :exec
|
|
DELETE FROM media_bookmarks
|
|
WHERE id = $1;
|
|
|
|
-- name: UpdateMediaBookmark :one
|
|
UPDATE media_bookmarks
|
|
SET
|
|
title = $2,
|
|
notes = $3,
|
|
position = $4,
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $5
|
|
RETURNING *;
|
|
|
|
-- ============================================================================
|
|
-- PROCESSING ISSUES QUERIES
|
|
-- ============================================================================
|
|
|
|
-- name: CreateProcessingIssue :one
|
|
INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (media_item_id, issue_type)
|
|
DO UPDATE SET issue_description = EXCLUDED.issue_description,
|
|
severity = EXCLUDED.severity,
|
|
resolved = false,
|
|
resolved_at = NULL
|
|
RETURNING *;
|
|
|
|
-- name: ListProcessingIssuesByLibrary :many
|
|
SELECT
|
|
pi.id,
|
|
pi.media_item_id,
|
|
pi.issue_type,
|
|
pi.issue_description,
|
|
pi.severity,
|
|
pi.resolved,
|
|
pi.resolved_at,
|
|
pi.created_at,
|
|
mi.title,
|
|
mi.file_path,
|
|
mi.format_group,
|
|
lt.name as library_type_name
|
|
FROM processing_issues pi
|
|
JOIN media_items mi ON pi.media_item_id = mi.id
|
|
JOIN libraries l ON pi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE pi.library_id = $1
|
|
AND pi.resolved = false
|
|
ORDER BY
|
|
CASE pi.severity
|
|
WHEN 'error' THEN 1
|
|
WHEN 'warning' THEN 2
|
|
WHEN 'info' THEN 3
|
|
END,
|
|
pi.created_at DESC;
|
|
|
|
-- name: GetProcessingIssueStats :one
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE severity = 'error' AND resolved = false) as error_count,
|
|
COUNT(*) FILTER (WHERE severity = 'warning' AND resolved = false) as warning_count,
|
|
COUNT(*) FILTER (WHERE severity = 'info' AND resolved = false) as info_count
|
|
FROM processing_issues
|
|
WHERE library_id = $1;
|
|
|
|
-- name: ResolveProcessingIssue :one
|
|
UPDATE processing_issues
|
|
SET resolved = true,
|
|
resolved_at = NOW()
|
|
WHERE id = $1
|
|
AND media_item_id = $2
|
|
RETURNING *;
|
|
|
|
-- name: DeleteProcessingIssue :one
|
|
DELETE FROM processing_issues
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- ============================================================================
|
|
-- LIBRARY WITH TYPE INFO QUERIES
|
|
-- ============================================================================
|
|
|
|
-- name: GetLibraryWithType :one
|
|
SELECT
|
|
l.*,
|
|
lt.name as type_name,
|
|
lt.description as type_description,
|
|
lt.allowed_extensions
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE l.id = $1;
|
|
|
|
-- name: GetBooksByTag :many
|
|
SELECT * FROM media_items
|
|
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
|
ORDER BY title ASC;
|