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:
2026-01-30 16:07:00 -05:00
parent fa4a9c35bb
commit bb3c32c59f
6 changed files with 3101 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+15
View File
@@ -13,6 +13,8 @@ import (
type Querier interface {
// Library Folders queries
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
// Bulk update format group for all media items
BulkUpdateFormatGroups(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context) error
// Backward compatibility - Ebook Notes queries (using views)
CreateEbookNote(ctx context.Context, arg CreateEbookNoteParams) (MediaNotes, error)
@@ -25,6 +27,8 @@ type Querier interface {
// Media Notes queries
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
// Create reading history entry
CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error)
// Refresh Tokens queries
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error)
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
@@ -53,9 +57,13 @@ type Querier interface {
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
// Get reading history for a user and book
GetReadingHistory(ctx context.Context, arg GetReadingHistoryParams) ([]ReadingHistory, error)
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
GetRefreshToken(ctx context.Context, token string) (GetRefreshTokenRow, error)
GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanSettingsRow, error)
// Get universal progress for a book
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
@@ -83,11 +91,18 @@ type Querier interface {
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
// ============================================
// PHASE 1: FORMAT DETECTION & PROGRESS (Week 2)
// ============================================
// Update media item format group information
UpdateMediaItemFormatGroup(ctx context.Context, arg UpdateMediaItemFormatGroupParams) error
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
UpdateScanSettings(ctx context.Context, arg UpdateScanSettingsParams) error
// Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
+395
View File
@@ -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
`
+137
View File
@@ -484,4 +484,141 @@ UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at I
-- name: CleanupExpiredRefreshTokens :exec
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days');
-- ============================================
-- PHASE 1: FORMAT DETECTION & PROGRESS (Week 2)
-- ============================================
-- Update media item format group information
-- 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;
-- Bulk update format group for all media items
-- 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';
-- Get universal progress for a book
-- 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;
-- Update universal progress
-- 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 *;
-- Get reading history for a user and book
-- name: GetReadingHistory :many
SELECT *
FROM reading_history
WHERE user_id = $1 AND media_item_id = $2
ORDER BY created_at DESC
LIMIT $3;
-- Create reading history entry
-- 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 *;
-- Media Items Admin Operations
+132
View File
@@ -0,0 +1,132 @@
package sync
import (
"path/filepath"
"strings"
)
// FormatGroup represents the three main format categories
type FormatGroup string
const (
FormatGroupReflowable FormatGroup = "reflowable"
FormatGroupFixedLayout FormatGroup = "fixed_layout"
FormatGroupComicArchive FormatGroup = "comic_archive"
FormatGroupUnknown FormatGroup = "unknown"
)
// MimeType mappings for common ebook formats
var mimeTypes = map[string]string{
".epub": "application/epub+zip",
".mobi": "application/x-mobipocket-ebook",
".azw": "application/x-mobipocket-ebook",
".azw3": "application/vnd.amazon.mobi8-ebook",
".pdf": "application/pdf",
".djvu": "image/vnd.djvu",
".cbz": "application/x-cbz",
".cbr": "application/x-cbr",
".cb7": "application/x-cb7",
".cbt": "application/x-cbt",
".fb2": "application/x-fictionbook+xml",
".txt": "text/plain",
".rtf": "application/rtf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".lit": "application/x-ms-reader",
".pdb": "application/vnd.palm",
".prc": "application/vnd.palm",
}
// ReflowableFormats are formats that support text reflow
var ReflowableFormats = map[string]bool{
".epub": true,
".mobi": true,
".azw": true,
".azw3": true,
".fb2": true,
".txt": true,
".rtf": true,
".doc": true,
".docx": true,
".lit": true,
".pdb": true,
".prc": true,
}
// FixedLayoutFormats are formats with fixed page layouts
var FixedLayoutFormats = map[string]bool{
".pdf": true,
".djvu": true,
}
// ComicArchiveFormats are comic archive formats
var ComicArchiveFormats = map[string]bool{
".cbz": true,
".cbr": true,
".cb7": true,
".cbt": true,
}
// DetectFormatGroup determines the format group based on mimetype and file path
func DetectFormatGroup(mimetype string, filePath string) FormatGroup {
ext := strings.ToLower(filepath.Ext(filePath))
// Check by mimetype first
switch mimetype {
case "application/epub+zip",
"application/x-mobipocket-ebook",
"application/vnd.amazon.mobi8-ebook",
"application/x-fictionbook+xml",
"text/plain":
return FormatGroupReflowable
case "application/pdf",
"image/vnd.djvu":
return FormatGroupFixedLayout
case "application/x-cbr",
"application/x-cbz",
"application/x-cb7",
"application/x-cbt":
return FormatGroupComicArchive
}
// Fall back to file extension
if ReflowableFormats[ext] {
return FormatGroupReflowable
}
if FixedLayoutFormats[ext] {
return FormatGroupFixedLayout
}
if ComicArchiveFormats[ext] {
return FormatGroupComicArchive
}
return FormatGroupUnknown
}
// GetMimeType returns the mimetype for a given file extension
func GetMimeType(filePath string) string {
ext := strings.ToLower(filepath.Ext(filePath))
if mt, ok := mimeTypes[ext]; ok {
return mt
}
return ""
}
// IsReflowable checks if a format is reflowable
func IsReflowable(formatGroup FormatGroup) bool {
return formatGroup == FormatGroupReflowable
}
// HasFixedLayout checks if a format has fixed layout
func HasFixedLayout(formatGroup FormatGroup) bool {
return formatGroup == FormatGroupFixedLayout
}
// IsComicArchive checks if a format is a comic archive
func IsComicArchive(formatGroup FormatGroup) bool {
return formatGroup == FormatGroupComicArchive
}
+255
View File
@@ -0,0 +1,255 @@
package sync
import (
"encoding/json"
"fmt"
"math"
)
// ProgressData represents universal progress data with multiple location references
type ProgressData struct {
Percentage *float64 `json:"percentage,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
Character *int64 `json:"character,omitempty"`
Chapter *int `json:"chapter,omitempty"`
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
ViewportY *float64 `json:"viewport_y,omitempty"`
Page *int `int,omitempty"`
TotalPages *int `int,omitempty"`
PageY *int `int,omitempty"`
Zoom *float64 `json:"zoom,omitempty"`
ScrollX *float64 `json:"scroll_x,omitempty"`
ScrollY *float64 `json:"scroll_y,omitempty"`
Panel *int `json:"panel,omitempty"`
ReadingMode *string `json:"reading_mode,omitempty"`
TotalCharacters *int64 `json:"total_characters,omitempty"`
}
// DeviceProgress represents progress from a specific device
type DeviceProgress struct {
Source string `json:"source"`
Data ProgressData `json:"data"`
Timestamp string `json:"timestamp,omitempty"`
}
// ConvertProgress converts progress between different format groups
func ConvertProgress(sourceFormat, targetFormat FormatGroup, sourceData map[string]interface{}) (map[string]interface{}, error) {
percentage := extractPercentage(sourceFormat, sourceData)
result := make(map[string]interface{})
switch targetFormat {
case FormatGroupReflowable:
result["percentage"] = percentage
if epubcfi, ok := sourceData["epubcfi"].(string); ok {
result["epubcfi"] = epubcfi
} else {
// Generate approximate CFI from percentage
result["epubcfi"] = fmt.Sprintf("epubcfi(/6/4/2:%d)", int(percentage*100))
}
if totalChars, ok := sourceData["total_characters"].(int64); ok {
result["character"] = int64(float64(totalChars) * percentage)
}
case FormatGroupFixedLayout:
totalPages := 200.0
if tp, ok := sourceData["total_pages"].(int); ok {
totalPages = float64(tp)
}
result["page"] = int(math.Round(percentage * totalPages))
result["total_pages"] = int(totalPages)
result["percentage"] = percentage
if pageY, ok := sourceData["page_y"].(int); ok {
result["page_y"] = pageY
}
case FormatGroupComicArchive:
totalPages := 32.0
if tp, ok := sourceData["total_pages"].(int); ok {
totalPages = float64(tp)
}
result["page"] = int(math.Round(percentage * totalPages))
result["total_pages"] = int(totalPages)
result["percentage"] = percentage
if panel, ok := sourceData["panel"].(int); ok {
result["panel"] = panel
}
default:
return nil, fmt.Errorf("unsupported target format: %s", targetFormat)
}
return result, nil
}
// extractPercentage extracts the percentage (0.0-1.0) from source data
func extractPercentage(sourceFormat FormatGroup, sourceData map[string]interface{}) float64 {
switch sourceFormat {
case FormatGroupReflowable:
if p, ok := sourceData["percentage"].(float64); ok {
return p
}
// Try to calculate from character offset
if char, ok := sourceData["character"].(int64); ok {
if total, ok := sourceData["total_characters"].(int64); ok && total > 0 {
return float64(char) / float64(total)
}
}
case FormatGroupFixedLayout, FormatGroupComicArchive:
if page, ok := sourceData["page"].(int); ok {
if total, ok := sourceData["total_pages"].(int); ok && total > 0 {
return float64(page) / float64(total)
}
}
// Try direct percentage
if p, ok := sourceData["percentage"].(float64); ok {
return p
}
}
return 0.0
}
// PageToPercentage converts page/total_pages to percentage
func PageToPercentage(page, totalPages int) float64 {
if totalPages <= 0 {
return 0.0
}
percentage := float64(page) / float64(totalPages)
if percentage > 1.0 {
percentage = 1.0
}
if percentage < 0.0 {
percentage = 0.0
}
return percentage
}
// PercentageToPage converts percentage to page number
func PercentageToPage(percentage float64, totalPages int) int {
if percentage < 0.0 {
percentage = 0.0
}
if percentage > 1.0 {
percentage = 1.0
}
page := int(math.Round(float64(totalPages) * percentage))
if page < 0 {
page = 0
}
if page > totalPages {
page = totalPages
}
return page
}
// CharacterToPercentage converts character offset to percentage
func CharacterToPercentage(character, totalCharacters int64) float64 {
if totalCharacters <= 0 {
return 0.0
}
percentage := float64(character) / float64(totalCharacters)
if percentage > 1.0 {
percentage = 1.0
}
if percentage < 0.0 {
percentage = 0.0
}
return percentage
}
// PercentageToCharacter converts percentage to character offset
func PercentageToCharacter(percentage float64, totalCharacters int64) int64 {
if percentage < 0.0 {
percentage = 0.0
}
if percentage > 1.0 {
percentage = 1.0
}
char := int64(math.Round(float64(totalCharacters) * percentage))
if char < 0 {
char = 0
}
if char > totalCharacters {
char = totalCharacters
}
return char
}
// MergeProgress merges progress from two sources using "max progress wins" strategy
func MergeProgress(progressA, progressB map[string]interface{}) map[string]interface{} {
percA := extractPercentage(FormatGroupReflowable, progressA)
percB := extractPercentage(FormatGroupReflowable, progressB)
winner := progressB
if percA > percB {
winner = progressA
}
result := make(map[string]interface{})
for k, v := range winner {
result[k] = v
}
sources := []string{}
if srcA, ok := progressA["source"].(string); ok {
sources = append(sources, srcA)
}
if srcB, ok := progressB["source"].(string); ok {
sources = append(sources, srcB)
}
result["merged_from"] = sources
result["merge_timestamp"] = "now"
return result
}
// FormatProgressForDisplay formats progress for display based on format group
func FormatProgressForDisplay(formatGroup FormatGroup, progress map[string]interface{}) string {
percentage := extractPercentage(formatGroup, progress)
switch formatGroup {
case FormatGroupReflowable:
if chapter, ok := progress["chapter"].(int); ok {
return fmt.Sprintf("%.1f%% (Chapter %d)", percentage*100, chapter)
}
return fmt.Sprintf("%.1f%%", percentage*100)
case FormatGroupFixedLayout:
if page, ok := progress["page"].(int); ok {
if total, ok := progress["total_pages"].(int); ok {
return fmt.Sprintf("Page %d of %d (%.1f%%)", page, total, percentage*100)
}
return fmt.Sprintf("Page %d (%.1f%%)", page, percentage*100)
}
return fmt.Sprintf("%.1f%%", percentage*100)
case FormatGroupComicArchive:
if page, ok := progress["page"].(int); ok {
if total, ok := progress["total_pages"].(int); ok {
return fmt.Sprintf("Page %d of %d (%.0f%%)", page, total, percentage*100)
}
return fmt.Sprintf("Page %d (%.0f%%)", page, percentage*100)
}
return fmt.Sprintf("%.0f%%", percentage*100)
default:
return fmt.Sprintf("%.1f%%", percentage*100)
}
}
// ParseProgressFromJSON parses progress data from JSON
func ParseProgressFromJSON(data []byte) (*ProgressData, error) {
var progress ProgressData
err := json.Unmarshal(data, &progress)
if err != nil {
return nil, err
}
return &progress, nil
}
// MarshalProgressToJSON converts progress data to JSON
func MarshalProgressToJSON(progress *ProgressData) ([]byte, error) {
return json.Marshal(progress)
}