Phase 1 Week 2: Format detection and progress conversion engine
- Add internal/sync package with format detection - FormatGroup types: reflowable, fixed_layout, comic_archive - DetectFormatGroup() function based on mimetype and file extension - MimeType mappings for common ebook formats - Progress conversion engine with: - ConvertProgress() between format groups - Extract percentage from various progress formats - PageToPercentage / PercentageToPage helpers - CharacterToPercentage / PercentageToCharacter helpers - MergeProgress() with 'max progress wins' strategy - FormatProgressForDisplay() for UI rendering - Add sqlc queries for format detection and progress updates - BulkUpdateFormatGroups query for auto-format detection - GetUniversalProgress query with all location references - UpdateUniversalProgress query with device sync metadata - ReadingHistory queries for session tracking
This commit is contained in:
@@ -33,6 +33,23 @@ func (q *Queries) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderPara
|
||||
return i, err
|
||||
}
|
||||
|
||||
const BulkUpdateFormatGroups = `-- name: BulkUpdateFormatGroups :exec
|
||||
UPDATE media_items m
|
||||
SET
|
||||
format_group = detect_format_group(m.mime_type, m.file_path),
|
||||
format_mimetype = m.mime_type,
|
||||
is_reflowable = (detect_format_group(m.mime_type, m.file_path) = 'reflowable'),
|
||||
has_fixed_layout = (detect_format_group(m.mime_type, m.file_path) IN ('fixed_layout', 'comic_archive')),
|
||||
updated_at = NOW()
|
||||
WHERE m.format_group IS NULL OR m.format_group = 'unknown'
|
||||
`
|
||||
|
||||
// Bulk update format group for all media items
|
||||
func (q *Queries) BulkUpdateFormatGroups(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, BulkUpdateFormatGroups)
|
||||
return 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')
|
||||
`
|
||||
@@ -345,6 +362,64 @@ func (q *Queries) CreateMediaRating(ctx context.Context, arg CreateMediaRatingPa
|
||||
return i, err
|
||||
}
|
||||
|
||||
const CreateReadingHistory = `-- name: CreateReadingHistory :one
|
||||
INSERT INTO reading_history (
|
||||
user_id,
|
||||
media_item_id,
|
||||
device_id,
|
||||
progress_percentage,
|
||||
reading_session_start,
|
||||
reading_session_end,
|
||||
pages_read,
|
||||
time_spent_seconds,
|
||||
device_metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, user_id, media_item_id, device_id, progress_percentage, reading_session_start, reading_session_end, pages_read, time_spent_seconds, device_metadata, created_at
|
||||
`
|
||||
|
||||
type CreateReadingHistoryParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||
ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"`
|
||||
ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"`
|
||||
ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"`
|
||||
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
|
||||
TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"`
|
||||
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
||||
}
|
||||
|
||||
// Create reading history entry
|
||||
func (q *Queries) CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error) {
|
||||
row := q.db.QueryRow(ctx, CreateReadingHistory,
|
||||
arg.UserID,
|
||||
arg.MediaItemID,
|
||||
arg.DeviceID,
|
||||
arg.ProgressPercentage,
|
||||
arg.ReadingSessionStart,
|
||||
arg.ReadingSessionEnd,
|
||||
arg.PagesRead,
|
||||
arg.TimeSpentSeconds,
|
||||
arg.DeviceMetadata,
|
||||
)
|
||||
var i ReadingHistory
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.MediaItemID,
|
||||
&i.DeviceID,
|
||||
&i.ProgressPercentage,
|
||||
&i.ReadingSessionStart,
|
||||
&i.ReadingSessionEnd,
|
||||
&i.PagesRead,
|
||||
&i.TimeSpentSeconds,
|
||||
&i.DeviceMetadata,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const CreateRefreshToken = `-- name: CreateRefreshToken :one
|
||||
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -1029,6 +1104,53 @@ func (q *Queries) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetReadingHistory = `-- name: GetReadingHistory :many
|
||||
SELECT id, user_id, media_item_id, device_id, progress_percentage, reading_session_start, reading_session_end, pages_read, time_spent_seconds, device_metadata, created_at
|
||||
FROM reading_history
|
||||
WHERE user_id = $1 AND media_item_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type GetReadingHistoryParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
Limit int32 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
// Get reading history for a user and book
|
||||
func (q *Queries) GetReadingHistory(ctx context.Context, arg GetReadingHistoryParams) ([]ReadingHistory, error) {
|
||||
rows, err := q.db.Query(ctx, GetReadingHistory, arg.UserID, arg.MediaItemID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ReadingHistory{}
|
||||
for rows.Next() {
|
||||
var i ReadingHistory
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.MediaItemID,
|
||||
&i.DeviceID,
|
||||
&i.ProgressPercentage,
|
||||
&i.ReadingSessionStart,
|
||||
&i.ReadingSessionEnd,
|
||||
&i.PagesRead,
|
||||
&i.TimeSpentSeconds,
|
||||
&i.DeviceMetadata,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetReadingProgress = `-- name: GetReadingProgress :one
|
||||
SELECT id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, chapter, chapter_progress, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved FROM reading_progress WHERE media_item_id = $1 AND user_id = $2
|
||||
`
|
||||
@@ -1121,6 +1243,117 @@ func (q *Queries) GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanS
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUniversalProgress = `-- name: GetUniversalProgress :one
|
||||
SELECT
|
||||
rp.id,
|
||||
rp.media_item_id,
|
||||
rp.user_id,
|
||||
rp.current_page,
|
||||
rp.total_pages,
|
||||
rp.last_read_at,
|
||||
rp.percentage,
|
||||
rp.character_offset,
|
||||
rp.epubcfi,
|
||||
rp.chapter,
|
||||
rp.chapter_progress,
|
||||
rp.viewport_x,
|
||||
rp.viewport_y,
|
||||
rp.zoom_level,
|
||||
rp.scroll_position_x,
|
||||
rp.scroll_position_y,
|
||||
rp.panel_number,
|
||||
rp.reading_mode,
|
||||
rp.last_sync_device,
|
||||
rp.last_sync_source,
|
||||
rp.last_sync_timestamp,
|
||||
rp.conflict_detected,
|
||||
rp.conflict_resolved,
|
||||
mi.format_group,
|
||||
mi.format_mimetype,
|
||||
mi.is_reflowable,
|
||||
mi.has_fixed_layout,
|
||||
mi.total_characters,
|
||||
mi.chapter_count
|
||||
FROM reading_progress rp
|
||||
JOIN media_items mi ON rp.media_item_id = mi.id
|
||||
WHERE rp.media_item_id = $1 AND rp.user_id = $2
|
||||
`
|
||||
|
||||
type GetUniversalProgressParams struct {
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
type GetUniversalProgressRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
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"`
|
||||
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"`
|
||||
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
||||
ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"`
|
||||
ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"`
|
||||
ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"`
|
||||
ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"`
|
||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||
ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"`
|
||||
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
|
||||
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
|
||||
LastSyncTimestamp pgtype.Timestamptz `db:"last_sync_timestamp" json:"last_sync_timestamp"`
|
||||
ConflictDetected pgtype.Bool `db:"conflict_detected" json:"conflict_detected"`
|
||||
ConflictResolved pgtype.Bool `db:"conflict_resolved" json:"conflict_resolved"`
|
||||
FormatGroup string `db:"format_group" json:"format_group"`
|
||||
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
|
||||
IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"`
|
||||
HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"`
|
||||
TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"`
|
||||
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
|
||||
}
|
||||
|
||||
// Get universal progress for a book
|
||||
func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) {
|
||||
row := q.db.QueryRow(ctx, GetUniversalProgress, arg.MediaItemID, arg.UserID)
|
||||
var i GetUniversalProgressRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.MediaItemID,
|
||||
&i.UserID,
|
||||
&i.CurrentPage,
|
||||
&i.TotalPages,
|
||||
&i.LastReadAt,
|
||||
&i.Percentage,
|
||||
&i.CharacterOffset,
|
||||
&i.Epubcfi,
|
||||
&i.Chapter,
|
||||
&i.ChapterProgress,
|
||||
&i.ViewportX,
|
||||
&i.ViewportY,
|
||||
&i.ZoomLevel,
|
||||
&i.ScrollPositionX,
|
||||
&i.ScrollPositionY,
|
||||
&i.PanelNumber,
|
||||
&i.ReadingMode,
|
||||
&i.LastSyncDevice,
|
||||
&i.LastSyncSource,
|
||||
&i.LastSyncTimestamp,
|
||||
&i.ConflictDetected,
|
||||
&i.ConflictResolved,
|
||||
&i.FormatGroup,
|
||||
&i.FormatMimetype,
|
||||
&i.IsReflowable,
|
||||
&i.HasFixedLayout,
|
||||
&i.TotalCharacters,
|
||||
&i.ChapterCount,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUser = `-- name: GetUser :one
|
||||
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE id = $1
|
||||
`
|
||||
@@ -2587,6 +2820,47 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpdateMediaItemFormatGroup = `-- name: UpdateMediaItemFormatGroup :exec
|
||||
|
||||
UPDATE media_items
|
||||
SET
|
||||
format_group = $2,
|
||||
format_mimetype = $3,
|
||||
is_reflowable = $4,
|
||||
has_fixed_layout = $5,
|
||||
total_characters = $6,
|
||||
chapter_count = $7,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdateMediaItemFormatGroupParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
FormatGroup string `db:"format_group" json:"format_group"`
|
||||
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
|
||||
IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"`
|
||||
HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"`
|
||||
TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"`
|
||||
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PHASE 1: FORMAT DETECTION & PROGRESS (Week 2)
|
||||
// ============================================
|
||||
// Update media item format group information
|
||||
func (q *Queries) UpdateMediaItemFormatGroup(ctx context.Context, arg UpdateMediaItemFormatGroupParams) error {
|
||||
_, err := q.db.Exec(ctx, UpdateMediaItemFormatGroup,
|
||||
arg.ID,
|
||||
arg.FormatGroup,
|
||||
arg.FormatMimetype,
|
||||
arg.IsReflowable,
|
||||
arg.HasFixedLayout,
|
||||
arg.TotalCharacters,
|
||||
arg.ChapterCount,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdateMediaNote = `-- name: UpdateMediaNote :one
|
||||
UPDATE media_notes SET
|
||||
content = $2,
|
||||
@@ -2735,6 +3009,127 @@ func (q *Queries) UpdateScanSettings(ctx context.Context, arg UpdateScanSettings
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdateUniversalProgress = `-- name: UpdateUniversalProgress :one
|
||||
INSERT INTO reading_progress (
|
||||
media_item_id,
|
||||
user_id,
|
||||
percentage,
|
||||
character_offset,
|
||||
epubcfi,
|
||||
chapter,
|
||||
chapter_progress,
|
||||
viewport_x,
|
||||
viewport_y,
|
||||
zoom_level,
|
||||
scroll_position_x,
|
||||
scroll_position_y,
|
||||
panel_number,
|
||||
reading_mode,
|
||||
last_sync_device,
|
||||
last_sync_source,
|
||||
last_sync_timestamp,
|
||||
current_page,
|
||||
total_pages,
|
||||
last_read_at
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, NOW()
|
||||
)
|
||||
ON CONFLICT (media_item_id, user_id)
|
||||
DO UPDATE SET
|
||||
percentage = EXCLUDED.percentage,
|
||||
character_offset = EXCLUDED.character_offset,
|
||||
epubcfi = EXCLUDED.epubcfi,
|
||||
chapter = EXCLUDED.chapter,
|
||||
chapter_progress = EXCLUDED.chapter_progress,
|
||||
viewport_x = EXCLUDED.viewport_x,
|
||||
viewport_y = EXCLUDED.viewport_y,
|
||||
zoom_level = EXCLUDED.zoom_level,
|
||||
scroll_position_x = EXCLUDED.scroll_position_x,
|
||||
scroll_position_y = EXCLUDED.scroll_position_y,
|
||||
panel_number = EXCLUDED.panel_number,
|
||||
reading_mode = EXCLUDED.reading_mode,
|
||||
last_sync_device = EXCLUDED.last_sync_device,
|
||||
last_sync_source = EXCLUDED.last_sync_source,
|
||||
last_sync_timestamp = EXCLUDED.last_sync_timestamp,
|
||||
current_page = EXCLUDED.current_page,
|
||||
total_pages = EXCLUDED.total_pages,
|
||||
last_read_at = NOW()
|
||||
RETURNING id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, chapter, chapter_progress, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved
|
||||
`
|
||||
|
||||
type UpdateUniversalProgressParams 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"`
|
||||
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
||||
ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"`
|
||||
ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"`
|
||||
ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"`
|
||||
ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"`
|
||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||
ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"`
|
||||
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
|
||||
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
|
||||
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
|
||||
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
|
||||
}
|
||||
|
||||
// Update universal progress
|
||||
func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error) {
|
||||
row := q.db.QueryRow(ctx, UpdateUniversalProgress,
|
||||
arg.MediaItemID,
|
||||
arg.UserID,
|
||||
arg.Percentage,
|
||||
arg.CharacterOffset,
|
||||
arg.Epubcfi,
|
||||
arg.Chapter,
|
||||
arg.ChapterProgress,
|
||||
arg.ViewportX,
|
||||
arg.ViewportY,
|
||||
arg.ZoomLevel,
|
||||
arg.ScrollPositionX,
|
||||
arg.ScrollPositionY,
|
||||
arg.PanelNumber,
|
||||
arg.ReadingMode,
|
||||
arg.LastSyncDevice,
|
||||
arg.LastSyncSource,
|
||||
arg.CurrentPage,
|
||||
arg.TotalPages,
|
||||
)
|
||||
var i ReadingProgress
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.MediaItemID,
|
||||
&i.UserID,
|
||||
&i.CurrentPage,
|
||||
&i.TotalPages,
|
||||
&i.LastReadAt,
|
||||
&i.Percentage,
|
||||
&i.CharacterOffset,
|
||||
&i.Epubcfi,
|
||||
&i.Chapter,
|
||||
&i.ChapterProgress,
|
||||
&i.ViewportX,
|
||||
&i.ViewportY,
|
||||
&i.ZoomLevel,
|
||||
&i.ScrollPositionX,
|
||||
&i.ScrollPositionY,
|
||||
&i.PanelNumber,
|
||||
&i.ReadingMode,
|
||||
&i.LastSyncDevice,
|
||||
&i.LastSyncSource,
|
||||
&i.LastSyncTimestamp,
|
||||
&i.ConflictDetected,
|
||||
&i.ConflictResolved,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpdateUserProfile = `-- name: UpdateUserProfile :exec
|
||||
UPDATE users SET first_name = $2, last_name = $3, updated_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user