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:
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ import (
|
|||||||
type Querier interface {
|
type Querier interface {
|
||||||
// Library Folders queries
|
// Library Folders queries
|
||||||
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
|
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
|
CleanupExpiredRefreshTokens(ctx context.Context) error
|
||||||
// Backward compatibility - Ebook Notes queries (using views)
|
// Backward compatibility - Ebook Notes queries (using views)
|
||||||
CreateEbookNote(ctx context.Context, arg CreateEbookNoteParams) (MediaNotes, error)
|
CreateEbookNote(ctx context.Context, arg CreateEbookNoteParams) (MediaNotes, error)
|
||||||
@@ -25,6 +27,8 @@ type Querier interface {
|
|||||||
// Media Notes queries
|
// Media Notes queries
|
||||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||||
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
||||||
|
// Create reading history entry
|
||||||
|
CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error)
|
||||||
// Refresh Tokens queries
|
// Refresh Tokens queries
|
||||||
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error)
|
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error)
|
||||||
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
|
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
|
||||||
@@ -53,9 +57,13 @@ type Querier interface {
|
|||||||
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)
|
||||||
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, 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)
|
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
|
||||||
GetRefreshToken(ctx context.Context, token string) (GetRefreshTokenRow, error)
|
GetRefreshToken(ctx context.Context, token string) (GetRefreshTokenRow, error)
|
||||||
GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanSettingsRow, 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)
|
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
|
||||||
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
||||||
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
|
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
|
||||||
@@ -83,11 +91,18 @@ type Querier interface {
|
|||||||
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
||||||
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
|
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
|
||||||
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, 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)
|
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
|
||||||
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
||||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||||
UpdateScanSettings(ctx context.Context, arg UpdateScanSettingsParams) 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
|
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
||||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ func (q *Queries) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderPara
|
|||||||
return i, err
|
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
|
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')
|
||||||
`
|
`
|
||||||
@@ -345,6 +362,64 @@ func (q *Queries) CreateMediaRating(ctx context.Context, arg CreateMediaRatingPa
|
|||||||
return i, err
|
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
|
const CreateRefreshToken = `-- name: CreateRefreshToken :one
|
||||||
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -1029,6 +1104,53 @@ func (q *Queries) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID)
|
|||||||
return items, nil
|
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
|
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
|
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
|
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
|
const GetUser = `-- name: GetUser :one
|
||||||
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE id = $1
|
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
|
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
|
const UpdateMediaNote = `-- name: UpdateMediaNote :one
|
||||||
UPDATE media_notes SET
|
UPDATE media_notes SET
|
||||||
content = $2,
|
content = $2,
|
||||||
@@ -2735,6 +3009,127 @@ func (q *Queries) UpdateScanSettings(ctx context.Context, arg UpdateScanSettings
|
|||||||
return err
|
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
|
const UpdateUserProfile = `-- name: UpdateUserProfile :exec
|
||||||
UPDATE users SET first_name = $2, last_name = $3, updated_at = NOW() WHERE id = $1
|
UPDATE users SET first_name = $2, last_name = $3, updated_at = NOW() WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -484,4 +484,141 @@ UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at I
|
|||||||
-- name: CleanupExpiredRefreshTokens :exec
|
-- 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');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 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
|
-- Media Items Admin Operations
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user