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