Phase 3 Week 7: Add KOReader bulk sync function to database schema

- Add bulk_update_progress_from_koreader() function for batch processing
- Handles progress, annotations, and conflict detection
- Returns success/failure status for each book
- Supports device matching by UUID, file path, or title/author
- Implements automatic conflict detection for concurrent syncs
- Part of Phase 3 KOReader Integration implementation
This commit is contained in:
2026-01-30 20:54:51 -05:00
parent 9789bc25a7
commit 4c01c0d12e
4 changed files with 636 additions and 2 deletions
+119
View File
@@ -597,3 +597,122 @@ BEGIN
); );
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- ============================================
-- KOREADER SYNC FUNCTIONS (Phase 3)
-- ============================================
-- 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,
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,
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 ? '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;
+11
View File
@@ -15,6 +15,8 @@ type Querier interface {
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
// Bulk update format group for all media items // Bulk update format group for all media items
BulkUpdateFormatGroups(ctx context.Context) error BulkUpdateFormatGroups(ctx context.Context) error
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
CleanupExpiredRefreshTokens(ctx context.Context) error CleanupExpiredRefreshTokens(ctx context.Context) error
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
// ============================================ // ============================================
@@ -39,6 +41,7 @@ type Querier interface {
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error)
// Conflict Resolution // Conflict Resolution
CreateSyncConflict(ctx context.Context, arg CreateSyncConflictParams) (SyncConflicts, error) CreateSyncConflict(ctx context.Context, arg CreateSyncConflictParams) (SyncConflicts, error)
CreateSyncHistoryEntry(ctx context.Context, arg CreateSyncHistoryEntryParams) (SyncQueue, error)
// Sync Queue Management // Sync Queue Management
CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueItemParams) (SyncQueue, error) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueItemParams) (SyncQueue, error)
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
@@ -55,6 +58,7 @@ type Querier interface {
DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error
DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error
DeleteUser(ctx context.Context, id pgtype.UUID) error DeleteUser(ctx context.Context, id pgtype.UUID) error
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error) GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error)
GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error) GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error)
GetDeviceByIdentifier(ctx context.Context, deviceIdentifier string) (Devices, error) GetDeviceByIdentifier(ctx context.Context, deviceIdentifier string) (Devices, error)
@@ -70,6 +74,10 @@ type Querier interface {
GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error) GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error)
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
GetMediaItemByFilePath(ctx context.Context, filePath string) (MediaItems, error) GetMediaItemByFilePath(ctx context.Context, filePath string) (MediaItems, error)
// ============================================
// PHASE 3: KOREADER SYNC PROTOCOL (Weeks 7-9)
// ============================================
GetMediaItemByFilePathForSync(ctx context.Context, filePath string) (MediaItems, error)
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error) GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error) GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error) GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
@@ -88,7 +96,9 @@ type Querier interface {
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error) GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error) GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error)
GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error)
GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error) GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error)
GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error)
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
@@ -115,6 +125,7 @@ type Querier interface {
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error) UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
UpdateDeviceLastSeen(ctx context.Context, id pgtype.UUID) (Devices, error) UpdateDeviceLastSeen(ctx context.Context, id pgtype.UUID) (Devices, error)
UpdateDeviceLastSync(ctx context.Context, id pgtype.UUID) (Devices, error) UpdateDeviceLastSync(ctx context.Context, id pgtype.UUID) (Devices, error)
UpdateDeviceSyncTimestamp(ctx context.Context, id pgtype.UUID) (Devices, error)
UpdateEbookNote(ctx context.Context, arg UpdateEbookNoteParams) (MediaNotes, error) UpdateEbookNote(ctx context.Context, arg UpdateEbookNoteParams) (MediaNotes, error)
UpdateEmail(ctx context.Context, arg UpdateEmailParams) error UpdateEmail(ctx context.Context, arg UpdateEmailParams) error
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error) UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
+397
View File
@@ -50,6 +50,57 @@ func (q *Queries) BulkUpdateFormatGroups(ctx context.Context) error {
return err return err
} }
const BulkUpdateProgressFromSync = `-- name: BulkUpdateProgressFromSync :many
SELECT bulk_update_progress_from_koreader FROM bulk_update_progress_from_koreader($1::uuid, $2::jsonb)
`
type BulkUpdateProgressFromSyncParams struct {
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
Column2 []byte `db:"column_2" json:"column_2"`
}
func (q *Queries) BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error) {
rows, err := q.db.Query(ctx, BulkUpdateProgressFromSync, arg.Column1, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []interface{}{}
for rows.Next() {
var bulk_update_progress_from_koreader interface{}
if err := rows.Scan(&bulk_update_progress_from_koreader); err != nil {
return nil, err
}
items = append(items, bulk_update_progress_from_koreader)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const CheckForProgressConflicts = `-- 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
`
type CheckForProgressConflictsParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
}
func (q *Queries) CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error) {
row := q.db.QueryRow(ctx, CheckForProgressConflicts, arg.MediaItemID, arg.UserID, arg.LastSyncSource)
var conflict_count int64
err := row.Scan(&conflict_count)
return conflict_count, err
}
const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days') DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')
` `
@@ -548,6 +599,38 @@ func (q *Queries) CreateSyncConflict(ctx context.Context, arg CreateSyncConflict
return i, err return i, err
} }
const CreateSyncHistoryEntry = `-- 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 id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at
`
type CreateSyncHistoryEntryParams struct {
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
SyncData []byte `db:"sync_data" json:"sync_data"`
}
func (q *Queries) CreateSyncHistoryEntry(ctx context.Context, arg CreateSyncHistoryEntryParams) (SyncQueue, error) {
row := q.db.QueryRow(ctx, CreateSyncHistoryEntry, arg.DeviceID, arg.MediaItemID, arg.SyncData)
var i SyncQueue
err := row.Scan(
&i.ID,
&i.DeviceID,
&i.MediaItemID,
&i.SyncType,
&i.SyncData,
&i.Priority,
&i.Attempts,
&i.MaxAttempts,
&i.Status,
&i.ErrorMessage,
&i.CreatedAt,
&i.ProcessedAt,
)
return i, err
}
const CreateSyncQueueItem = `-- name: CreateSyncQueueItem :one const CreateSyncQueueItem = `-- name: CreateSyncQueueItem :one
INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, max_attempts, status) 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) VALUES ($1, $2, $3, $4, $5, $6, $7)
@@ -785,6 +868,94 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
return err return err
} }
const GetAnnotationsForBook = `-- 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
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
ORDER BY created_at DESC
`
type GetAnnotationsForBookParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
type GetAnnotationsForBookRow struct {
ID pgtype.UUID `db:"id" json:"id"`
SelectionText string `db:"selection_text" json:"selection_text"`
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
Color pgtype.Text `db:"color" json:"color"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
AnnotationType string `db:"annotation_type" json:"annotation_type"`
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
}
func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error) {
rows, err := q.db.Query(ctx, GetAnnotationsForBook, arg.MediaItemID, arg.UserID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetAnnotationsForBookRow{}
for rows.Next() {
var i GetAnnotationsForBookRow
if err := rows.Scan(
&i.ID,
&i.SelectionText,
&i.StartPosition,
&i.EndPosition,
&i.Color,
&i.CreatedAt,
&i.UpdatedAt,
&i.AnnotationType,
&i.PercentageStart,
&i.PercentageEnd,
&i.EpubcfiStart,
&i.EpubcfiEnd,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetDevice = `-- name: GetDevice :one const GetDevice = `-- name: GetDevice :one
SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE id = $1 SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE id = $1
` `
@@ -1225,6 +1396,56 @@ func (q *Queries) GetMediaItemByFilePath(ctx context.Context, filePath string) (
return i, err return i, err
} }
const GetMediaItemByFilePathForSync = `-- name: GetMediaItemByFilePathForSync :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count FROM media_items WHERE file_path = $1
`
// ============================================
// PHASE 3: KOREADER SYNC PROTOCOL (Weeks 7-9)
// ============================================
func (q *Queries) GetMediaItemByFilePathForSync(ctx context.Context, filePath string) (MediaItems, error) {
row := q.db.QueryRow(ctx, GetMediaItemByFilePathForSync, filePath)
var i MediaItems
err := row.Scan(
&i.ID,
&i.LibraryID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
&i.Language,
&i.Edition,
&i.PageCount,
&i.Genre,
&i.CopyrightYear,
&i.GoodreadsID,
&i.OpenlibraryID,
&i.GoogleBooksID,
&i.AddedByAdminID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FormatGroup,
&i.FormatMimetype,
&i.IsReflowable,
&i.HasFixedLayout,
&i.TotalCharacters,
&i.ChapterCount,
)
return i, err
}
const GetMediaNote = `-- name: GetMediaNote :one const GetMediaNote = `-- name: GetMediaNote :one
SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data FROM media_notes WHERE id = $1 SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data FROM media_notes WHERE id = $1
` `
@@ -1827,6 +2048,67 @@ func (q *Queries) GetUserForLogin(ctx context.Context, email string) (GetUserFor
return i, err return i, err
} }
const GetUserMediaItemsForSync = `-- 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
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
`
type GetUserMediaItemsForSyncRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
FilePath string `db:"file_path" json:"file_path"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
FormatGroup string `db:"format_group" json:"format_group"`
TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"`
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
}
func (q *Queries) GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error) {
rows, err := q.db.Query(ctx, GetUserMediaItemsForSync, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetUserMediaItemsForSyncRow{}
for rows.Next() {
var i GetUserMediaItemsForSyncRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.FilePath,
&i.MimeType,
&i.PageCount,
&i.FormatGroup,
&i.TotalCharacters,
&i.ChapterCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUserPasswordHash = `-- name: GetUserPasswordHash :one const GetUserPasswordHash = `-- name: GetUserPasswordHash :one
SELECT password_hash FROM users WHERE id = $1 SELECT password_hash FROM users WHERE id = $1
` `
@@ -1838,6 +2120,89 @@ func (q *Queries) GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (stri
return password_hash, err return password_hash, err
} }
const GetUserProgressForBooks = `-- 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
`
type GetUserProgressForBooksParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
}
type GetUserProgressForBooksRow struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
FilePath string `db:"file_path" json:"file_path"`
}
func (q *Queries) GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error) {
rows, err := q.db.Query(ctx, GetUserProgressForBooks, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetUserProgressForBooksRow{}
for rows.Next() {
var i GetUserProgressForBooksRow
if err := rows.Scan(
&i.MediaItemID,
&i.UserID,
&i.Percentage,
&i.CharacterOffset,
&i.Epubcfi,
&i.Chapter,
&i.ChapterProgress,
&i.CurrentPage,
&i.TotalPages,
&i.LastReadAt,
&i.LastSyncDevice,
&i.LastSyncSource,
&i.Title,
&i.Author,
&i.FilePath,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUserVisibleLibraries = `-- name: GetUserVisibleLibraries :many const GetUserVisibleLibraries = `-- name: GetUserVisibleLibraries :many
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description, SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description,
COALESCE(lv.is_visible, true) as is_visible COALESCE(lv.is_visible, true) as is_visible
@@ -3263,6 +3628,38 @@ func (q *Queries) UpdateDeviceLastSync(ctx context.Context, id pgtype.UUID) (Dev
return i, err return i, err
} }
const UpdateDeviceSyncTimestamp = `-- name: UpdateDeviceSyncTimestamp :one
UPDATE devices
SET
last_sync = NOW(),
last_seen = NOW(),
updated_at = NOW()
WHERE id = $1
RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at
`
func (q *Queries) UpdateDeviceSyncTimestamp(ctx context.Context, id pgtype.UUID) (Devices, error) {
row := q.db.QueryRow(ctx, UpdateDeviceSyncTimestamp, id)
var i Devices
err := row.Scan(
&i.ID,
&i.UserID,
&i.DeviceName,
&i.DeviceType,
&i.DeviceIdentifier,
&i.AuthToken,
&i.LastSync,
&i.LastSeen,
&i.SyncEnabled,
&i.AutoSync,
&i.SyncFrequencyMinutes,
&i.DeviceMetadata,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const UpdateEbookNote = `-- name: UpdateEbookNote :one const UpdateEbookNote = `-- name: UpdateEbookNote :one
UPDATE media_notes SET UPDATE media_notes SET
content = $2, content = $2,
+108 -1
View File
@@ -754,4 +754,111 @@ RETURNING *;
-- name: DeleteSyncConflict :exec -- name: DeleteSyncConflict :exec
DELETE FROM sync_conflicts WHERE id = $1; DELETE FROM sync_conflicts WHERE id = $1;
-- Media Items Admin Operations -- ============================================
-- PHASE 3: KOREADER SYNC PROTOCOL (Weeks 7-9)
-- ============================================
-- 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
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
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
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;
-- Media Items Admin Operations