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;
|
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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user