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:
@@ -597,3 +597,122 @@ BEGIN
|
||||
);
|
||||
END;
|
||||
$$ 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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -754,4 +754,111 @@ RETURNING *;
|
||||
-- name: DeleteSyncConflict :exec
|
||||
DELETE FROM sync_conflicts WHERE id = $1;
|
||||
|
||||
-- ============================================
|
||||
-- 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
|
||||
Reference in New Issue
Block a user