From 4c01c0d12e18ebbead3138c01c859674cbcc9322 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Jan 2026 20:54:51 -0500 Subject: [PATCH] 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 --- database/schema/schema.sql | 121 +++++++- internal/database/querier.go | 11 + internal/database/queries.sql.go | 397 ++++++++++++++++++++++++++ internal/database/queries/queries.sql | 109 ++++++- 4 files changed, 636 insertions(+), 2 deletions(-) diff --git a/database/schema/schema.sql b/database/schema/schema.sql index da93ed9..332b363 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -596,4 +596,123 @@ BEGIN 'merge_timestamp', NOW() ); END; -$$ LANGUAGE plpgsql; \ No newline at end of file +$$ 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; diff --git a/internal/database/querier.go b/internal/database/querier.go index df60c7d..3d2f438 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -15,6 +15,8 @@ type Querier interface { AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error) // Bulk update format group for all media items 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 ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error // ============================================ @@ -39,6 +41,7 @@ type Querier interface { CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error) // Conflict Resolution CreateSyncConflict(ctx context.Context, arg CreateSyncConflictParams) (SyncConflicts, error) + CreateSyncHistoryEntry(ctx context.Context, arg CreateSyncHistoryEntryParams) (SyncQueue, error) // Sync Queue Management CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueItemParams) (SyncQueue, error) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) @@ -55,6 +58,7 @@ type Querier interface { DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error DeleteSyncQueueItem(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) GetDeviceByAuthToken(ctx context.Context, authToken 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) GetMediaItem(ctx context.Context, id pgtype.UUID) (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) GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error) GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error) @@ -88,7 +96,9 @@ type Querier interface { GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, 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) + GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) ListDevicesByType(ctx context.Context, deviceType string) ([]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) UpdateDeviceLastSeen(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) UpdateEmail(ctx context.Context, arg UpdateEmailParams) error UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 3067631..06ff65f 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -50,6 +50,57 @@ func (q *Queries) BulkUpdateFormatGroups(ctx context.Context) error { 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 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 } +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 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) @@ -785,6 +868,94 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error { 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 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 } +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 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 } +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 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 } +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 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 @@ -3263,6 +3628,38 @@ func (q *Queries) UpdateDeviceLastSync(ctx context.Context, id pgtype.UUID) (Dev 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 UPDATE media_notes SET content = $2, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 14068f6..ea28195 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -754,4 +754,111 @@ RETURNING *; -- name: DeleteSyncConflict :exec DELETE FROM sync_conflicts WHERE id = $1; --- Media Items Admin Operations \ No newline at end of file +-- ============================================ +-- 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 \ No newline at end of file