From 980aaee0d90d7290e164775059d169cea6dc4fbe Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 11:08:18 -0400 Subject: [PATCH 01/52] refactor(setup): derive setup-complete status from admin user count Setup completion was previously tracked by a manually-flipped setup_complete row in system_settings, written via a JWT-protected PUT /api/setup/complete endpoint. This meant any admin user created outside the setup wizard (future CLI, seed scripts, direct DB inserts) would not flip the switch, leaving the app stuck redirecting to /setup. The trigger is now derived from real data: setup is complete iff at least one admin user exists. This is self-correcting regardless of how users are created, and re-engages setup automatically if all admins are ever removed. Changes: - Add internal/setupstatus package with IsSetupComplete() (queries CountAdmins, 10s in-memory cache, fails open on DB error) and Invalidate() to clear the cache. Uses an AdminCounter interface to avoid importing the database package. - Add CountAdmins sqlc query (SELECT COUNT(*) FROM users WHERE role = 'admin') and regenerate. - Rewire router/setup.go isSetupComplete() to delegate to setupstatus; drop the old setup_complete setting read, cache vars, and the PUT /api/setup/complete route. - Call setupstatus.Invalidate() in the auth handler after CreateUser, UpdateUserRole, and DeleteUser so the cache reflects admin-count changes immediately. - Align first-user promotion in Register to key off !adminExists instead of len(users) == 0, so the two checks cannot diverge. - Remove the now-dead SetSetupComplete/GetSetupStatus handlers. - Drop the setup_complete seed row from schema.sql. - Remove the apiPut('/setup/complete') call from the setup wizard finishSetup(); the admin account created in submitAdmin already marks setup complete server-side. --- database/schema/schema.sql | 3 +- internal/database/querier.go | 1 + internal/database/queries.sql.go | 11 +++++ internal/database/queries/queries.sql | 3 ++ internal/handlers/auth.go | 45 +++++++++++-------- internal/handlers/system_settings.go | 26 ----------- internal/router/setup.go | 62 +-------------------------- internal/setupstatus/status.go | 62 +++++++++++++++++++++++++++ web/src/setup.ts | 8 ++-- 9 files changed, 111 insertions(+), 110 deletions(-) create mode 100644 internal/setupstatus/status.go diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 25eee9f..5ecf45d 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -49,8 +49,7 @@ CREATE TABLE IF NOT EXISTS system_settings ( INSERT INTO system_settings (setting_key, setting_value, description) VALUES ('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'), ('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'), -('default_timezone', 'UTC', 'System default timezone'), -('setup_complete', 'false', 'Whether the initial setup wizard has been completed') +('default_timezone', 'UTC', 'System default timezone') ON CONFLICT (setting_key) DO NOTHING; -- Create refresh_tokens table diff --git a/internal/database/querier.go b/internal/database/querier.go index 1d7b71d..1412be4 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -30,6 +30,7 @@ type Querier interface { ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error + CountAdmins(ctx context.Context) (int64, error) // Count unlinked books for a device CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 03b44ea..d65967d 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -226,6 +226,17 @@ func (q *Queries) ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfBy return err } +const CountAdmins = `-- name: CountAdmins :one +SELECT COUNT(*) FROM users WHERE role = 'admin' +` + +func (q *Queries) CountAdmins(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, CountAdmins) + var count int64 + err := row.Scan(&count) + return count, err +} + const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one SELECT COUNT(*) as count FROM unlinked_books diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index a9fd9ea..55823de 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -346,6 +346,9 @@ WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1; +-- name: CountAdmins :one +SELECT COUNT(*) FROM users WHERE role = 'admin'; + -- name: ReassignLibraries :exec UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1; diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index e847377..8710290 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -6,6 +6,7 @@ package handlers import ( "bookhoard/internal/database" "bookhoard/internal/middleware" + "bookhoard/internal/setupstatus" "context" "errors" "fmt" @@ -82,22 +83,22 @@ type UserProfile struct { } type UpdateProfileRequest struct { - Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"` - Email string `json:"email,omitempty" validate:"omitempty,email"` - FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"` - LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"` - Theme string `json:"theme,omitempty" validate:"omitempty"` - Timezone string `json:"timezone,omitempty" validate:"omitempty"` + Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"` + Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"` + FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"` + LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"` + Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"` + Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"` } type AdminUpdateUserRequest struct { - Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"` - Email string `json:"email,omitempty" validate:"omitempty,email"` - FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"` - LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"` - Theme string `json:"theme,omitempty" validate:"omitempty"` - Timezone string `json:"timezone,omitempty" validate:"omitempty"` - Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"` + Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"` + Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"` + FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"` + LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"` + Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"` + Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"` + Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"` } // Register handles POST /api/auth/register @@ -190,7 +191,7 @@ func (h *AuthHandler) Register(c *echo.Context) error { } var userRole string - if len(users) == 0 { + if !adminExists { userRole = "admin" } else { userRole = req.Role @@ -232,6 +233,10 @@ func (h *AuthHandler) Register(c *echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } + // A new user may have changed the admin count (e.g. first user becomes + // admin), so refresh the setup-status cache. + setupstatus.Invalidate() + if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
Failed to create default collections
`) @@ -548,6 +553,9 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error { if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } + + // Role changes can affect the admin count, so refresh the setup-status cache. + setupstatus.Invalidate() } // Update username (if provided) @@ -785,9 +793,9 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error { } type PasswordRequest struct { - CurrentPassword string `json:"current_password,omitempty"` - NewPassword string `json:"new_password" validate:"required,passwordcomplex"` - ConfirmPassword string `json:"confirm_password" validate:"required"` + CurrentPassword string `json:"current_password,omitempty" form:"current_password"` + NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"` + ConfirmPassword string `json:"confirm_password" form:"confirm_password" validate:"required"` } var req PasswordRequest @@ -934,6 +942,9 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } + // Deletion may have changed the admin count, so refresh the setup-status cache. + setupstatus.Invalidate() + // Create success message based on context var message string if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes { diff --git a/internal/handlers/system_settings.go b/internal/handlers/system_settings.go index fd3209f..7e3c1eb 100644 --- a/internal/handlers/system_settings.go +++ b/internal/handlers/system_settings.go @@ -95,32 +95,6 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error { }) } -func (h *SystemSettingsHandler) SetSetupComplete(c *echo.Context) error { - err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{ - SettingKey: "setup_complete", - SettingValue: "true", - }) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - return c.JSON(http.StatusOK, map[string]string{"message": "setup complete"}) -} - -func (h *SystemSettingsHandler) GetSetupStatus(c *echo.Context) error { - val, err := h.db.GetSystemSetting(c.Request().Context(), "setup_complete") - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return c.JSON(http.StatusOK, map[string]bool{"setup_complete": false}) - } - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - complete, err := strconv.ParseBool(val) - if err != nil { - complete = false - } - return c.JSON(http.StatusOK, map[string]bool{"setup_complete": complete}) -} - func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error { scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds") if err != nil { diff --git a/internal/router/setup.go b/internal/router/setup.go index d8e69a2..d951635 100644 --- a/internal/router/setup.go +++ b/internal/router/setup.go @@ -3,65 +3,18 @@ package router import ( "bytes" "context" - "errors" "log" "net/http" - "strconv" "strings" - "sync" - "time" + "bookhoard/internal/setupstatus" "bookhoard/templates" - "github.com/jackc/pgx/v5" "github.com/labstack/echo/v5" ) -var ( - setupCacheMu sync.RWMutex - setupCacheComplete bool = true - setupCacheExpiry time.Time - setupCacheTTL = 10 * time.Second -) - func isSetupComplete(cfg *Config) bool { - setupCacheMu.RLock() - if time.Now().Before(setupCacheExpiry) { - complete := setupCacheComplete - setupCacheMu.RUnlock() - return complete - } - setupCacheMu.RUnlock() - - val, err := cfg.Queries.GetSystemSetting(context.Background(), "setup_complete") - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - setupCacheMu.Lock() - setupCacheComplete = false - setupCacheExpiry = time.Now().Add(setupCacheTTL) - setupCacheMu.Unlock() - return false - } - return true - } - - complete, err := strconv.ParseBool(val) - if err != nil { - complete = false - } - - setupCacheMu.Lock() - setupCacheComplete = complete - setupCacheExpiry = time.Now().Add(setupCacheTTL) - setupCacheMu.Unlock() - return complete -} - -func invalidateSetupCache() { - setupCacheMu.Lock() - setupCacheComplete = true - setupCacheExpiry = time.Time{} - setupCacheMu.Unlock() + return setupstatus.IsSetupComplete(context.Background(), cfg.Queries) } func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc { @@ -104,15 +57,4 @@ func registerSetupRoutes(cfg *Config) { } return c.HTML(http.StatusOK, buf.String()) }) - - jwtMiddleware := createJWTMiddleware(cfg) - protected := e.Group("/api/setup", jwtMiddleware) - protected.PUT("/complete", func(c *echo.Context) error { - err := cfg.SystemSettingsHandler.SetSetupComplete(c) - if err != nil { - return err - } - invalidateSetupCache() - return nil - }) } diff --git a/internal/setupstatus/status.go b/internal/setupstatus/status.go new file mode 100644 index 0000000..d95fc19 --- /dev/null +++ b/internal/setupstatus/status.go @@ -0,0 +1,62 @@ +// Package setupstatus reports whether the application's initial setup has been +// completed. Setup is considered complete as soon as at least one admin user +// exists, regardless of how that user was created (setup wizard, API, or a +// future CLI). This keeps the setup gate a derived property of real data +// rather than a manually-flipped flag that can drift out of sync. +package setupstatus + +import ( + "context" + "sync" + "time" +) + +// AdminCounter is satisfied by *database.Queries. It is defined as an interface +// here so this package does not import the database package, keeping the +// dependency graph flat and avoiding import cycles. +type AdminCounter interface { + CountAdmins(ctx context.Context) (int64, error) +} + +var ( + cacheMu sync.RWMutex + cacheComplete bool = true + cacheExpiry time.Time + cacheTTL = 10 * time.Second +) + +// IsSetupComplete reports whether setup is complete. Setup is complete when at +// least one admin user exists. A short in-memory cache avoids hammering the +// database on every request. On a database error the function fails open +// (returns true) so a transient outage does not lock users out of the app. +func IsSetupComplete(ctx context.Context, q AdminCounter) bool { + cacheMu.RLock() + if time.Now().Before(cacheExpiry) { + complete := cacheComplete + cacheMu.RUnlock() + return complete + } + cacheMu.RUnlock() + + count, err := q.CountAdmins(ctx) + complete := true + if err == nil { + complete = count > 0 + } + + cacheMu.Lock() + cacheComplete = complete + cacheExpiry = time.Now().Add(cacheTTL) + cacheMu.Unlock() + return complete +} + +// Invalidate clears the cached setup status so the next call to IsSetupComplete +// re-reads from the database. Call this after any write that could change the +// admin user count (user creation, role promotion/demotion, user deletion). +func Invalidate() { + cacheMu.Lock() + cacheComplete = true + cacheExpiry = time.Time{} + cacheMu.Unlock() +} diff --git a/web/src/setup.ts b/web/src/setup.ts index 94209e0..2031434 100644 --- a/web/src/setup.ts +++ b/web/src/setup.ts @@ -460,15 +460,13 @@ function initSetup(): void { async finishSetup() { this.loading = true; try { - const response = await apiPut("/setup/complete"); - await handleVoidResponse(response); + // Setup completion is derived from the existence of an admin user, so + // there is no separate "complete" endpoint to call. The admin account + // created in submitAdmin already marks setup as done server-side. if (this.libraries.length > 0) { setSelectedLibrary(this.libraries[0].id); } window.location.href = "/dashboard"; - } catch (err) { - handleError(err, "Failed to complete setup"); - window.location.href = "/dashboard"; } finally { this.loading = false; } From 78176c57a59feef7591a3e72bcc32bb227abcc03 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 11:08:25 -0400 Subject: [PATCH 02/52] chore(compose): quote numeric env var values Quote DATABASE_PORT and SERVER_PORT ("5432", "8765") in docker-compose.yml so they are treated as strings rather than YAML integers, avoiding type-coercion warnings from compose runtimes. --- docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index befdfb3..c652ef1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,14 +35,14 @@ services: environment: # Database Configuration DATABASE_HOST: db - DATABASE_PORT: 5432 + DATABASE_PORT: "5432" DATABASE_USER: postgres DATABASE_PASSWORD: ${DBPASS} DATABASE_NAME: bookhoard # Application Configuration JWT_SECRET: ${JWT_SECRET} - SERVER_PORT: 8765 + SERVER_PORT: "8765" # IMPORTANT: Device sync requires full URL with protocol # Local: http://localhost:8765 # Local network: http://192.168.1.X:8765 @@ -86,7 +86,7 @@ services: environment: # Database Configuration DATABASE_HOST: db - DATABASE_PORT: 5432 + DATABASE_PORT: "5432" DATABASE_USER: postgres DATABASE_PASSWORD: ${DBPASS} DATABASE_NAME: bookhoard @@ -94,7 +94,7 @@ services: # Application Configuration JWT_SECRET: ${JWT_SECRET} - SERVER_PORT: 8765 + SERVER_PORT: "8765" # Test Configuration TEST_MODE: "true" From 2a15effc3ec4722f4950114ca4da61af20ea9fb5 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 14:48:30 -0400 Subject: [PATCH 03/52] feat(db): add annotation sync schema, queries, and tombstone support Add migration columns to media_highlights, media_notes, and media_bookmarks for cross-device annotation sync: - dedup_key: SHA-1 of normalized selection text + bucketed position, used as the stable cross-device identity for annotations - last_modified_at / last_modified_source: edit clock for LWW resolution and cross-source conflict detection - deleted / deleted_at: sticky tombstone columns for delete-wins semantics with a 30-day TTL before rows are physically purged - note_text on highlights: stores attached notes from KOReader entries that have both selected text and a user note - Location columns on bookmarks (cfi_position, percentage_location, epubcfi_location, chapter_reference, paragraph_reference) - device_sync_data JSONB on all three tables: stores per-device native identifiers (e.g. KOReader pos0/datetime, Kobo bookmark_id) so each device can locate and manipulate its own copy of an annotation Add partial unique indexes on (user_id, media_item_id, dedup_key) where deleted = FALSE to enforce one active annotation per dedup key. Add tombstone purge indexes on (deleted, deleted_at) for efficient GC. New queries: - GetByDedupKey for all three tables (returns active or most-recent tombstone) - CreateFull / UpdateForSync for all three tables (populate sync columns) - TombstoneByDedupKey / TombstoneByID for all three tables - PurgeExpired* for all three tables (GC past TTL) - GetActiveAnnotationsForBook (filtered union of highlights + notes) - GetTombstonedAnnotationsForBook (union of all 3 deleted within TTL) - GetMediaBookmarks with deleted filter - GetMediaBookmark (singular) with deleted filter - Added deleted=FALSE filter to GetMediaHighlights, GetAnnotationsForBook - CreateAutoResolvedSyncConflict (INSERT with resolution_status='auto_resolved') --- database/schema/schema.sql | 45 ++ internal/database/db.go | 2 +- internal/database/models.go | 84 +- internal/database/querier.go | 36 +- internal/database/queries.sql.go | 1076 ++++++++++++++++++++++++- internal/database/queries/queries.sql | 263 +++++- 6 files changed, 1452 insertions(+), 54 deletions(-) diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 5ecf45d..9d72688 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -1312,3 +1312,48 @@ CREATE TABLE IF NOT EXISTS media_bookmarks ( CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id); CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id); + +-- ============================================ +-- ANNOTATION SYNC MIGRATIONS +-- Adds dedup_key, LWW timestamps, soft-delete, +-- and device_sync_data to annotation tables. +-- ============================================ + +ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40); +ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ; +ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30); +ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT; +ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE; +ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40); +ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ; +ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30); +ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE; +ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40); +ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ; +ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30); +ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB; +ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT; +ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT; +ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER; +ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE; +ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup + ON media_highlights (user_id, media_item_id, dedup_key) + WHERE dedup_key IS NOT NULL AND deleted = FALSE; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup + ON media_notes (user_id, media_item_id, dedup_key) + WHERE dedup_key IS NOT NULL AND deleted = FALSE; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup + ON media_bookmarks (user_id, media_item_id, dedup_key) + WHERE dedup_key IS NOT NULL AND deleted = FALSE; + +CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE; +CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE; +CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE; diff --git a/internal/database/db.go b/internal/database/db.go index 486aa36..bdf4241 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package database diff --git a/internal/database/models.go b/internal/database/models.go index a08dece..fa7d6a0 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package database @@ -155,40 +155,55 @@ type LibraryVisibility struct { } type MediaBookmarks 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"` - PageNumber pgtype.Int4 `db:"page_number" json:"page_number"` - ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"` - CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"` - Title string `db:"title" json:"title"` - Position pgtype.Text `db:"position" json:"position"` - Notes pgtype.Text `db:"notes" json:"notes"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + 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"` + PageNumber pgtype.Int4 `db:"page_number" json:"page_number"` + ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"` + CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"` + Title string `db:"title" json:"title"` + Position pgtype.Text `db:"position" json:"position"` + Notes pgtype.Text `db:"notes" json:"notes"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` + LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"` + LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"` + DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` + PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"` + EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"` + ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` + Deleted pgtype.Bool `db:"deleted" json:"deleted"` + DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` } type MediaHighlights 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"` - SelectionText string `db:"selection_text" json:"selection_text"` - StartPosition pgtype.Text `db:"start_position" json:"start_position"` - EndPosition pgtype.Text `db:"end_position" json:"end_position"` - Color pgtype.Text `db:"color" json:"color"` - NoteID pgtype.UUID `db:"note_id" json:"note_id"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` - PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"` - PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"` - CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"` - CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"` - EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"` - EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"` - ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` - ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"` - ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"` - PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"` - DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` + 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"` + SelectionText string `db:"selection_text" json:"selection_text"` + StartPosition pgtype.Text `db:"start_position" json:"start_position"` + EndPosition pgtype.Text `db:"end_position" json:"end_position"` + Color pgtype.Text `db:"color" json:"color"` + NoteID pgtype.UUID `db:"note_id" json:"note_id"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"` + PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"` + CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"` + CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"` + EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"` + EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"` + ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` + ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"` + ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"` + PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"` + DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` + LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"` + LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"` + NoteText pgtype.Text `db:"note_text" json:"note_text"` + Deleted pgtype.Bool `db:"deleted" json:"deleted"` + DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` } type MediaItemFormats struct { @@ -304,6 +319,11 @@ type MediaNotes struct { ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"` DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` + LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"` + LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"` + Deleted pgtype.Bool `db:"deleted" json:"deleted"` + DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` } type MediaRatings struct { diff --git a/internal/database/querier.go b/internal/database/querier.go index 1412be4..4a6f720 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package database @@ -34,6 +34,7 @@ type Querier interface { // Count unlinked books for a device CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) + CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error) // COLLECTIONS QUERIES // Create collection CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error) @@ -55,8 +56,10 @@ type Querier interface { // Libraries queries CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error) CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error) + CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error) // Media Highlights queries CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error) + CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error) // Media Items queries CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error) // MEDIA ITEM FORMATS QUERIES @@ -64,6 +67,7 @@ type Querier interface { CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error) // Media Notes queries CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error) + CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error) CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error) // OPDS TOKENS QUERIES // Create OPDS token @@ -126,6 +130,10 @@ type Querier interface { DeleteUser(ctx context.Context, id pgtype.UUID) error DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error GenerateKoboEntitlementId(ctx context.Context) (interface{}, error) + // ============================================ + // ANNOTATION SERVE QUERIES + // ============================================ + GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error) // Get all system config GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error) @@ -197,8 +205,17 @@ type Querier interface { // LIBRARY WITH TYPE INFO QUERIES // ============================================================================ GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error) + GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error) + // ============================================ + // ANNOTATION SYNC QUERIES (bookmarks) + // ============================================ + GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBookmarkByDedupKeyParams) (MediaBookmarks, error) GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error) GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error) + // ============================================ + // ANNOTATION SYNC QUERIES (highlights) + // ============================================ + GetMediaHighlightByDedupKey(ctx context.Context, arg GetMediaHighlightByDedupKeyParams) (MediaHighlights, error) GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error) @@ -221,6 +238,10 @@ type Querier interface { // Get media item formats GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error) GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error) + // ============================================ + // ANNOTATION SYNC QUERIES (notes) + // ============================================ + GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error) 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) @@ -258,6 +279,7 @@ type Querier interface { // System Settings queries GetSystemSetting(ctx context.Context, settingKey string) (string, error) GetSystemTimezone(ctx context.Context) (string, error) + GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) // Get universal progress for a book GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) // Get unlinked book by ContentId @@ -304,6 +326,9 @@ type Querier interface { // List unresolved unlinked books with pagination ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error) + PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error + PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error + PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error // Query media items by multiple identifiers with confidence scoring QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error) ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error @@ -334,6 +359,12 @@ type Querier interface { // Set system config SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error) SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error + TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error + TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error + TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error + TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error + TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error + TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error // Update collection UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error) @@ -357,7 +388,9 @@ type Querier interface { UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error) UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error) UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, error) + UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error) UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error) + UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error) UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error) // Update media item format @@ -375,6 +408,7 @@ type Querier interface { UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error) UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error) UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error) + UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (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) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index d65967d..4d8327c 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: queries.sql package database @@ -262,6 +262,44 @@ func (q *Queries) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int return count, err } +const CreateAutoResolvedSyncConflict = `-- name: CreateAutoResolvedSyncConflict :one +INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at) +VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW()) +RETURNING id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at +` + +type CreateAutoResolvedSyncConflictParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + ConflictType string `db:"conflict_type" json:"conflict_type"` + ConflictData []byte `db:"conflict_data" json:"conflict_data"` + ResolutionData []byte `db:"resolution_data" json:"resolution_data"` +} + +func (q *Queries) CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error) { + row := q.db.QueryRow(ctx, CreateAutoResolvedSyncConflict, + arg.MediaItemID, + arg.UserID, + arg.ConflictType, + arg.ConflictData, + arg.ResolutionData, + ) + var i SyncConflicts + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.ConflictType, + &i.ConflictData, + &i.ResolutionStatus, + &i.ResolutionData, + &i.ResolvedBy, + &i.ResolvedAt, + &i.CreatedAt, + ) + return i, err +} + const CreateCollection = `-- name: CreateCollection :one INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings) @@ -566,7 +604,7 @@ func (q *Queries) CreateLibrary(ctx context.Context, arg CreateLibraryParams) (L const CreateMediaBookmark = `-- name: CreateMediaBookmark :one INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at +RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at ` type CreateMediaBookmarkParams struct { @@ -603,6 +641,88 @@ func (q *Queries) CreateMediaBookmark(ctx context.Context, arg CreateMediaBookma &i.Position, &i.Notes, &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const CreateMediaBookmarkFull = `-- name: CreateMediaBookmarkFull :one +INSERT INTO media_bookmarks ( + media_item_id, user_id, page_number, chapter_number, + cfi_position, title, position, notes, + percentage_location, epubcfi_location, chapter_reference, + dedup_key, last_modified_at, last_modified_source, + device_sync_data +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 +) RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at +` + +type CreateMediaBookmarkFullParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + PageNumber pgtype.Int4 `db:"page_number" json:"page_number"` + ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"` + CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"` + Title string `db:"title" json:"title"` + Position pgtype.Text `db:"position" json:"position"` + Notes pgtype.Text `db:"notes" json:"notes"` + PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"` + EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"` + ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` + LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"` + LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"` + DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` +} + +func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error) { + row := q.db.QueryRow(ctx, CreateMediaBookmarkFull, + arg.MediaItemID, + arg.UserID, + arg.PageNumber, + arg.ChapterNumber, + arg.CfiPosition, + arg.Title, + arg.Position, + arg.Notes, + arg.PercentageLocation, + arg.EpubcfiLocation, + arg.ChapterReference, + arg.DedupKey, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaBookmarks + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.PageNumber, + &i.ChapterNumber, + &i.CfiPosition, + &i.Title, + &i.Position, + &i.Notes, + &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, ) return i, err } @@ -610,7 +730,7 @@ func (q *Queries) CreateMediaBookmark(ctx context.Context, arg CreateMediaBookma const CreateMediaHighlight = `-- name: CreateMediaHighlight :one INSERT INTO media_highlights (media_item_id, user_id, selection_text, start_position, end_position, color, note_id) VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data +RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at ` type CreateMediaHighlightParams struct { @@ -657,6 +777,97 @@ func (q *Queries) CreateMediaHighlight(ctx context.Context, arg CreateMediaHighl &i.ParagraphEnd, &i.PanelNumber, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const CreateMediaHighlightFull = `-- name: CreateMediaHighlightFull :one +INSERT INTO media_highlights ( + media_item_id, user_id, selection_text, + start_position, end_position, color, note_text, + percentage_start, percentage_end, + epubcfi_start, epubcfi_end, + chapter_reference, + dedup_key, last_modified_at, last_modified_source, + device_sync_data +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 +) RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at +` + +type CreateMediaHighlightFullParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + SelectionText string `db:"selection_text" json:"selection_text"` + StartPosition pgtype.Text `db:"start_position" json:"start_position"` + EndPosition pgtype.Text `db:"end_position" json:"end_position"` + Color pgtype.Text `db:"color" json:"color"` + NoteText pgtype.Text `db:"note_text" json:"note_text"` + PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"` + PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"` + EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"` + EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"` + ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` + LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"` + LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"` + DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` +} + +func (q *Queries) CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error) { + row := q.db.QueryRow(ctx, CreateMediaHighlightFull, + arg.MediaItemID, + arg.UserID, + arg.SelectionText, + arg.StartPosition, + arg.EndPosition, + arg.Color, + arg.NoteText, + arg.PercentageStart, + arg.PercentageEnd, + arg.EpubcfiStart, + arg.EpubcfiEnd, + arg.ChapterReference, + arg.DedupKey, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaHighlights + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.SelectionText, + &i.StartPosition, + &i.EndPosition, + &i.Color, + &i.NoteID, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageStart, + &i.PercentageEnd, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiStart, + &i.EpubcfiEnd, + &i.ChapterReference, + &i.ParagraphStart, + &i.ParagraphEnd, + &i.PanelNumber, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, ) return i, err } @@ -876,7 +1087,7 @@ func (q *Queries) CreateMediaItemFormat(ctx context.Context, arg CreateMediaItem const CreateMediaNote = `-- name: CreateMediaNote :one INSERT INTO media_notes (media_item_id, user_id, content, position) VALUES ($1, $2, $3, $4) -RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data +RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at ` type CreateMediaNoteParams struct { @@ -910,6 +1121,82 @@ func (q *Queries) CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams &i.ChapterReference, &i.ParagraphReference, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const CreateMediaNoteFull = `-- name: CreateMediaNoteFull :one +INSERT INTO media_notes ( + media_item_id, user_id, content, position, + percentage_location, character_start, character_end, + epubcfi_location, chapter_reference, paragraph_reference, + dedup_key, last_modified_at, last_modified_source, + device_sync_data +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 +) RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at +` + +type CreateMediaNoteFullParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Content string `db:"content" json:"content"` + Position pgtype.Text `db:"position" json:"position"` + PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"` + CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"` + CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"` + EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"` + ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` + ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` + LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"` + LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"` + DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` +} + +func (q *Queries) CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error) { + row := q.db.QueryRow(ctx, CreateMediaNoteFull, + arg.MediaItemID, + arg.UserID, + arg.Content, + arg.Position, + arg.PercentageLocation, + arg.CharacterStart, + arg.CharacterEnd, + arg.EpubcfiLocation, + arg.ChapterReference, + arg.ParagraphReference, + arg.DedupKey, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaNotes + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.Content, + &i.Position, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageLocation, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.ParagraphReference, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, ) return i, err } @@ -1803,6 +2090,114 @@ func (q *Queries) GenerateKoboEntitlementId(ctx context.Context) (interface{}, e return entitlement_id, err } +const GetActiveAnnotationsForBook = `-- name: GetActiveAnnotationsForBook :many + +SELECT + mh.id, + mh.selection_text, + mh.start_position, + mh.end_position, + mh.color, + mh.created_at, + mh.updated_at, + 'highlight' as annotation_type, + mh.percentage_start, + mh.percentage_end, + mh.epubcfi_start, + mh.epubcfi_end, + mh.note_text, + mh.dedup_key, + mh.last_modified_at, + mh.last_modified_source +FROM media_highlights mh +WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = FALSE +UNION ALL +SELECT + mn.id, + mn.content, + mn.position, + NULL as end_position, + NULL as color, + mn.created_at, + mn.updated_at, + 'note' as annotation_type, + mn.percentage_location as percentage_start, + NULL as percentage_end, + mn.epubcfi_location as epubcfi_start, + NULL as epubcfi_end, + NULL as note_text, + mn.dedup_key, + mn.last_modified_at, + mn.last_modified_source +FROM media_notes mn +WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = FALSE +ORDER BY created_at DESC +` + +type GetActiveAnnotationsForBookParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` +} + +type GetActiveAnnotationsForBookRow struct { + ID pgtype.UUID `db:"id" json:"id"` + SelectionText string `db:"selection_text" json:"selection_text"` + StartPosition pgtype.Text `db:"start_position" json:"start_position"` + EndPosition pgtype.Text `db:"end_position" json:"end_position"` + Color pgtype.Text `db:"color" json:"color"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + AnnotationType string `db:"annotation_type" json:"annotation_type"` + PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"` + PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"` + EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"` + EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"` + NoteText pgtype.Text `db:"note_text" json:"note_text"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` + LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"` + LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"` +} + +// ============================================ +// ANNOTATION SERVE QUERIES +// ============================================ +func (q *Queries) GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error) { + rows, err := q.db.Query(ctx, GetActiveAnnotationsForBook, arg.MediaItemID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetActiveAnnotationsForBookRow{} + for rows.Next() { + var i GetActiveAnnotationsForBookRow + if err := rows.Scan( + &i.ID, + &i.SelectionText, + &i.StartPosition, + &i.EndPosition, + &i.Color, + &i.CreatedAt, + &i.UpdatedAt, + &i.AnnotationType, + &i.PercentageStart, + &i.PercentageEnd, + &i.EpubcfiStart, + &i.EpubcfiEnd, + &i.NoteText, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetAllSystemConfig = `-- name: GetAllSystemConfig :many SELECT key, value, updated_at, updated_by FROM system_config ORDER BY key ` @@ -1878,7 +2273,7 @@ SELECT mh.epubcfi_start, mh.epubcfi_end FROM media_highlights mh -WHERE mh.media_item_id = $1 AND mh.user_id = $2 +WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE UNION ALL SELECT mn.id, @@ -1894,7 +2289,7 @@ SELECT mn.epubcfi_location as epubcfi_start, NULL as epubcfi_end FROM media_notes mn -WHERE mn.media_item_id = $1 AND mn.user_id = $2 +WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE ORDER BY created_at DESC ` @@ -4012,9 +4407,84 @@ func (q *Queries) GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLi return i, err } +const GetMediaBookmark = `-- name: GetMediaBookmark :one +SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks WHERE id = $1 +` + +func (q *Queries) GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error) { + row := q.db.QueryRow(ctx, GetMediaBookmark, id) + var i MediaBookmarks + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.PageNumber, + &i.ChapterNumber, + &i.CfiPosition, + &i.Title, + &i.Position, + &i.Notes, + &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const GetMediaBookmarkByDedupKey = `-- name: GetMediaBookmarkByDedupKey :one + +SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 +ORDER BY deleted ASC, deleted_at DESC NULLS LAST +LIMIT 1 +` + +type GetMediaBookmarkByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +// ============================================ +// ANNOTATION SYNC QUERIES (bookmarks) +// ============================================ +func (q *Queries) GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBookmarkByDedupKeyParams) (MediaBookmarks, error) { + row := q.db.QueryRow(ctx, GetMediaBookmarkByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + var i MediaBookmarks + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.PageNumber, + &i.ChapterNumber, + &i.CfiPosition, + &i.Title, + &i.Position, + &i.Notes, + &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + const GetMediaBookmarks = `-- name: GetMediaBookmarks :many -SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at FROM media_bookmarks -WHERE media_item_id = $1 AND user_id = $2 +SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks +WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC ` @@ -4043,6 +4513,15 @@ func (q *Queries) GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksPa &i.Position, &i.Notes, &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, ); err != nil { return nil, err } @@ -4055,7 +4534,7 @@ func (q *Queries) GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksPa } const GetMediaHighlight = `-- name: GetMediaHighlight :one -SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data FROM media_highlights WHERE id = $1 +SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at FROM media_highlights WHERE id = $1 ` func (q *Queries) GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error) { @@ -4083,12 +4562,70 @@ func (q *Queries) GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaH &i.ParagraphEnd, &i.PanelNumber, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const GetMediaHighlightByDedupKey = `-- name: GetMediaHighlightByDedupKey :one + +SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at FROM media_highlights +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 +ORDER BY deleted ASC, deleted_at DESC NULLS LAST +LIMIT 1 +` + +type GetMediaHighlightByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +// ============================================ +// ANNOTATION SYNC QUERIES (highlights) +// ============================================ +func (q *Queries) GetMediaHighlightByDedupKey(ctx context.Context, arg GetMediaHighlightByDedupKeyParams) (MediaHighlights, error) { + row := q.db.QueryRow(ctx, GetMediaHighlightByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + var i MediaHighlights + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.SelectionText, + &i.StartPosition, + &i.EndPosition, + &i.Color, + &i.NoteID, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageStart, + &i.PercentageEnd, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiStart, + &i.EpubcfiEnd, + &i.ChapterReference, + &i.ParagraphStart, + &i.ParagraphEnd, + &i.PanelNumber, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, ) return i, err } const GetMediaHighlights = `-- name: GetMediaHighlights :many -SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC +SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC ` type GetMediaHighlightsParams struct { @@ -4127,6 +4664,12 @@ func (q *Queries) GetMediaHighlights(ctx context.Context, arg GetMediaHighlights &i.ParagraphEnd, &i.PanelNumber, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, ); err != nil { return nil, err } @@ -4819,7 +5362,7 @@ func (q *Queries) GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UU } const GetMediaNote = `-- name: GetMediaNote :one -SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data FROM media_notes WHERE id = $1 +SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at FROM media_notes WHERE id = $1 ` func (q *Queries) GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error) { @@ -4840,12 +5383,61 @@ func (q *Queries) GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, &i.ChapterReference, &i.ParagraphReference, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const GetMediaNoteByDedupKey = `-- name: GetMediaNoteByDedupKey :one + +SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at FROM media_notes +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 +ORDER BY deleted ASC, deleted_at DESC NULLS LAST +LIMIT 1 +` + +type GetMediaNoteByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +// ============================================ +// ANNOTATION SYNC QUERIES (notes) +// ============================================ +func (q *Queries) GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error) { + row := q.db.QueryRow(ctx, GetMediaNoteByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + var i MediaNotes + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.Content, + &i.Position, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageLocation, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.ParagraphReference, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, ) return i, err } const GetMediaNotes = `-- name: GetMediaNotes :many -SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC +SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC ` type GetMediaNotesParams struct { @@ -4877,6 +5469,11 @@ func (q *Queries) GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([ &i.ChapterReference, &i.ParagraphReference, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, ); err != nil { return nil, err } @@ -6001,6 +6598,76 @@ func (q *Queries) GetSystemTimezone(ctx context.Context) (string, error) { return setting_value, err } +const GetTombstonedAnnotationsForBook = `-- name: GetTombstonedAnnotationsForBook :many +SELECT + mh.id, + mh.dedup_key, + 'highlight' as annotation_type, + mh.device_sync_data, + mh.deleted_at +FROM media_highlights mh +WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3 +UNION ALL +SELECT + mn.id, + mn.dedup_key, + 'note' as annotation_type, + mn.device_sync_data, + mn.deleted_at +FROM media_notes mn +WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3 +UNION ALL +SELECT + mb.id, + mb.dedup_key, + 'bookmark' as annotation_type, + mb.device_sync_data, + mb.deleted_at +FROM media_bookmarks mb +WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3 +ORDER BY deleted_at DESC +` + +type GetTombstonedAnnotationsForBookParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` +} + +type GetTombstonedAnnotationsForBookRow struct { + ID pgtype.UUID `db:"id" json:"id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` + AnnotationType string `db:"annotation_type" json:"annotation_type"` + DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` + DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` +} + +func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) { + rows, err := q.db.Query(ctx, GetTombstonedAnnotationsForBook, arg.MediaItemID, arg.UserID, arg.DeletedAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetTombstonedAnnotationsForBookRow{} + for rows.Next() { + var i GetTombstonedAnnotationsForBookRow + if err := rows.Scan( + &i.ID, + &i.DedupKey, + &i.AnnotationType, + &i.DeviceSyncData, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetUniversalProgress = `-- name: GetUniversalProgress :one SELECT rp.id, @@ -8196,6 +8863,33 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { return items, nil } +const PurgeExpiredBookmarkTombstones = `-- name: PurgeExpiredBookmarkTombstones :exec +DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1 +` + +func (q *Queries) PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, PurgeExpiredBookmarkTombstones, deletedAt) + return err +} + +const PurgeExpiredHighlightTombstones = `-- name: PurgeExpiredHighlightTombstones :exec +DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1 +` + +func (q *Queries) PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, PurgeExpiredHighlightTombstones, deletedAt) + return err +} + +const PurgeExpiredNoteTombstones = `-- name: PurgeExpiredNoteTombstones :exec +DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1 +` + +func (q *Queries) PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, PurgeExpiredNoteTombstones, deletedAt) + return err +} + const QueryMediaItemsByIdentifiers = `-- name: QueryMediaItemsByIdentifiers :many SELECT mi.id, @@ -9421,6 +10115,102 @@ func (q *Queries) SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibrary return err } +const TombstoneMediaBookmarkByDedupKey = `-- name: TombstoneMediaBookmarkByDedupKey :exec +UPDATE media_bookmarks SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE +` + +type TombstoneMediaBookmarkByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +func (q *Queries) TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error { + _, err := q.db.Exec(ctx, TombstoneMediaBookmarkByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + return err +} + +const TombstoneMediaBookmarkByID = `-- name: TombstoneMediaBookmarkByID :exec +UPDATE media_bookmarks SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE id = $1 +` + +func (q *Queries) TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, TombstoneMediaBookmarkByID, id) + return err +} + +const TombstoneMediaHighlightByDedupKey = `-- name: TombstoneMediaHighlightByDedupKey :exec +UPDATE media_highlights SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE +` + +type TombstoneMediaHighlightByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +func (q *Queries) TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error { + _, err := q.db.Exec(ctx, TombstoneMediaHighlightByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + return err +} + +const TombstoneMediaHighlightByID = `-- name: TombstoneMediaHighlightByID :exec +UPDATE media_highlights SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE id = $1 +` + +func (q *Queries) TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, TombstoneMediaHighlightByID, id) + return err +} + +const TombstoneMediaNoteByDedupKey = `-- name: TombstoneMediaNoteByDedupKey :exec +UPDATE media_notes SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE +` + +type TombstoneMediaNoteByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +func (q *Queries) TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error { + _, err := q.db.Exec(ctx, TombstoneMediaNoteByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + return err +} + +const TombstoneMediaNoteByID = `-- name: TombstoneMediaNoteByID :exec +UPDATE media_notes SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE id = $1 +` + +func (q *Queries) TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, TombstoneMediaNoteByID, id) + return err +} + const UpdateCollection = `-- name: UpdateCollection :one UPDATE collections SET @@ -9942,7 +10732,7 @@ SET position = $4, updated_at = NOW() WHERE id = $1 AND user_id = $5 -RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at +RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at ` type UpdateMediaBookmarkParams struct { @@ -9973,6 +10763,91 @@ func (q *Queries) UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookma &i.Position, &i.Notes, &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const UpdateMediaBookmarkForSync = `-- name: UpdateMediaBookmarkForSync :one +UPDATE media_bookmarks SET + page_number = $2, + chapter_number = $3, + cfi_position = $4, + title = $5, + position = $6, + notes = $7, + percentage_location = $8, + epubcfi_location = $9, + chapter_reference = $10, + last_modified_at = $11, + last_modified_source = $12, + device_sync_data = $13, + created_at = created_at +WHERE id = $1 +RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at +` + +type UpdateMediaBookmarkForSyncParams struct { + ID pgtype.UUID `db:"id" json:"id"` + PageNumber pgtype.Int4 `db:"page_number" json:"page_number"` + ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"` + CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"` + Title string `db:"title" json:"title"` + Position pgtype.Text `db:"position" json:"position"` + Notes pgtype.Text `db:"notes" json:"notes"` + PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"` + EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"` + ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` + LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"` + LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"` + DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` +} + +func (q *Queries) UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error) { + row := q.db.QueryRow(ctx, UpdateMediaBookmarkForSync, + arg.ID, + arg.PageNumber, + arg.ChapterNumber, + arg.CfiPosition, + arg.Title, + arg.Position, + arg.Notes, + arg.PercentageLocation, + arg.EpubcfiLocation, + arg.ChapterReference, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaBookmarks + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.PageNumber, + &i.ChapterNumber, + &i.CfiPosition, + &i.Title, + &i.Position, + &i.Notes, + &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, ) return i, err } @@ -9986,7 +10861,7 @@ UPDATE media_highlights SET note_id = $6, updated_at = NOW() WHERE id = $1 -RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data +RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at ` type UpdateMediaHighlightParams struct { @@ -10030,6 +10905,99 @@ func (q *Queries) UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighl &i.ParagraphEnd, &i.PanelNumber, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const UpdateMediaHighlightForSync = `-- name: UpdateMediaHighlightForSync :one +UPDATE media_highlights SET + selection_text = $2, + start_position = $3, + end_position = $4, + color = $5, + note_text = $6, + percentage_start = $7, + percentage_end = $8, + epubcfi_start = $9, + epubcfi_end = $10, + chapter_reference = $11, + last_modified_at = $12, + last_modified_source = $13, + device_sync_data = $14, + updated_at = NOW() +WHERE id = $1 +RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at +` + +type UpdateMediaHighlightForSyncParams struct { + ID pgtype.UUID `db:"id" json:"id"` + SelectionText string `db:"selection_text" json:"selection_text"` + StartPosition pgtype.Text `db:"start_position" json:"start_position"` + EndPosition pgtype.Text `db:"end_position" json:"end_position"` + Color pgtype.Text `db:"color" json:"color"` + NoteText pgtype.Text `db:"note_text" json:"note_text"` + PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"` + PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"` + EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"` + EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"` + ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` + LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"` + LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"` + DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` +} + +func (q *Queries) UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error) { + row := q.db.QueryRow(ctx, UpdateMediaHighlightForSync, + arg.ID, + arg.SelectionText, + arg.StartPosition, + arg.EndPosition, + arg.Color, + arg.NoteText, + arg.PercentageStart, + arg.PercentageEnd, + arg.EpubcfiStart, + arg.EpubcfiEnd, + arg.ChapterReference, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaHighlights + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.SelectionText, + &i.StartPosition, + &i.EndPosition, + &i.Color, + &i.NoteID, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageStart, + &i.PercentageEnd, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiStart, + &i.EpubcfiEnd, + &i.ChapterReference, + &i.ParagraphStart, + &i.ParagraphEnd, + &i.PanelNumber, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, ) return i, err } @@ -10591,7 +11559,7 @@ UPDATE media_notes SET position = $3, updated_at = NOW() WHERE id = $1 -RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data +RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at ` type UpdateMediaNoteParams struct { @@ -10618,6 +11586,84 @@ func (q *Queries) UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams &i.ChapterReference, &i.ParagraphReference, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const UpdateMediaNoteForSync = `-- name: UpdateMediaNoteForSync :one +UPDATE media_notes SET + content = $2, + position = $3, + percentage_location = $4, + character_start = $5, + character_end = $6, + epubcfi_location = $7, + chapter_reference = $8, + paragraph_reference = $9, + last_modified_at = $10, + last_modified_source = $11, + device_sync_data = $12, + updated_at = NOW() +WHERE id = $1 +RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at +` + +type UpdateMediaNoteForSyncParams struct { + ID pgtype.UUID `db:"id" json:"id"` + Content string `db:"content" json:"content"` + Position pgtype.Text `db:"position" json:"position"` + PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"` + CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"` + CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"` + EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"` + ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` + ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"` + LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"` + LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"` + DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` +} + +func (q *Queries) UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error) { + row := q.db.QueryRow(ctx, UpdateMediaNoteForSync, + arg.ID, + arg.Content, + arg.Position, + arg.PercentageLocation, + arg.CharacterStart, + arg.CharacterEnd, + arg.EpubcfiLocation, + arg.ChapterReference, + arg.ParagraphReference, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaNotes + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.Content, + &i.Position, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageLocation, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.ParagraphReference, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, ) return i, err } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 55823de..b26d576 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -696,7 +696,7 @@ RETURNING *; SELECT * FROM media_notes WHERE id = $1; -- name: GetMediaNotes :many -SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC; +SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC; -- name: UpdateMediaNote :one UPDATE media_notes SET @@ -719,7 +719,7 @@ RETURNING *; SELECT * FROM media_highlights WHERE id = $1; -- name: GetMediaHighlights :many -SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC; +SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC; -- name: UpdateMediaHighlight :one UPDATE media_highlights SET @@ -735,6 +735,251 @@ RETURNING *; -- name: DeleteMediaHighlight :exec DELETE FROM media_highlights WHERE id = $1; +-- ============================================ +-- ANNOTATION SYNC QUERIES (highlights) +-- ============================================ + +-- name: GetMediaHighlightByDedupKey :one +SELECT * FROM media_highlights +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 +ORDER BY deleted ASC, deleted_at DESC NULLS LAST +LIMIT 1; + +-- name: CreateMediaHighlightFull :one +INSERT INTO media_highlights ( + media_item_id, user_id, selection_text, + start_position, end_position, color, note_text, + percentage_start, percentage_end, + epubcfi_start, epubcfi_end, + chapter_reference, + dedup_key, last_modified_at, last_modified_source, + device_sync_data +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 +) RETURNING *; + +-- name: UpdateMediaHighlightForSync :one +UPDATE media_highlights SET + selection_text = $2, + start_position = $3, + end_position = $4, + color = $5, + note_text = $6, + percentage_start = $7, + percentage_end = $8, + epubcfi_start = $9, + epubcfi_end = $10, + chapter_reference = $11, + last_modified_at = $12, + last_modified_source = $13, + device_sync_data = $14, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: TombstoneMediaHighlightByDedupKey :exec +UPDATE media_highlights SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE; + +-- name: TombstoneMediaHighlightByID :exec +UPDATE media_highlights SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE id = $1; + +-- name: PurgeExpiredHighlightTombstones :exec +DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1; + +-- ============================================ +-- ANNOTATION SYNC QUERIES (notes) +-- ============================================ + +-- name: GetMediaNoteByDedupKey :one +SELECT * FROM media_notes +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 +ORDER BY deleted ASC, deleted_at DESC NULLS LAST +LIMIT 1; + +-- name: CreateMediaNoteFull :one +INSERT INTO media_notes ( + media_item_id, user_id, content, position, + percentage_location, character_start, character_end, + epubcfi_location, chapter_reference, paragraph_reference, + dedup_key, last_modified_at, last_modified_source, + device_sync_data +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 +) RETURNING *; + +-- name: UpdateMediaNoteForSync :one +UPDATE media_notes SET + content = $2, + position = $3, + percentage_location = $4, + character_start = $5, + character_end = $6, + epubcfi_location = $7, + chapter_reference = $8, + paragraph_reference = $9, + last_modified_at = $10, + last_modified_source = $11, + device_sync_data = $12, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: TombstoneMediaNoteByDedupKey :exec +UPDATE media_notes SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE; + +-- name: TombstoneMediaNoteByID :exec +UPDATE media_notes SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE id = $1; + +-- name: PurgeExpiredNoteTombstones :exec +DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1; + +-- ============================================ +-- ANNOTATION SYNC QUERIES (bookmarks) +-- ============================================ + +-- name: GetMediaBookmarkByDedupKey :one +SELECT * FROM media_bookmarks +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 +ORDER BY deleted ASC, deleted_at DESC NULLS LAST +LIMIT 1; + +-- name: CreateMediaBookmarkFull :one +INSERT INTO media_bookmarks ( + media_item_id, user_id, page_number, chapter_number, + cfi_position, title, position, notes, + percentage_location, epubcfi_location, chapter_reference, + dedup_key, last_modified_at, last_modified_source, + device_sync_data +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 +) RETURNING *; + +-- name: UpdateMediaBookmarkForSync :one +UPDATE media_bookmarks SET + page_number = $2, + chapter_number = $3, + cfi_position = $4, + title = $5, + position = $6, + notes = $7, + percentage_location = $8, + epubcfi_location = $9, + chapter_reference = $10, + last_modified_at = $11, + last_modified_source = $12, + device_sync_data = $13, + created_at = created_at +WHERE id = $1 +RETURNING *; + +-- name: TombstoneMediaBookmarkByDedupKey :exec +UPDATE media_bookmarks SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE; + +-- name: TombstoneMediaBookmarkByID :exec +UPDATE media_bookmarks SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE id = $1; + +-- name: PurgeExpiredBookmarkTombstones :exec +DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1; + +-- ============================================ +-- ANNOTATION SERVE QUERIES +-- ============================================ + +-- name: GetActiveAnnotationsForBook :many +SELECT + mh.id, + mh.selection_text, + mh.start_position, + mh.end_position, + mh.color, + mh.created_at, + mh.updated_at, + 'highlight' as annotation_type, + mh.percentage_start, + mh.percentage_end, + mh.epubcfi_start, + mh.epubcfi_end, + mh.note_text, + mh.dedup_key, + mh.last_modified_at, + mh.last_modified_source +FROM media_highlights mh +WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = FALSE +UNION ALL +SELECT + mn.id, + mn.content, + mn.position, + NULL as end_position, + NULL as color, + mn.created_at, + mn.updated_at, + 'note' as annotation_type, + mn.percentage_location as percentage_start, + NULL as percentage_end, + mn.epubcfi_location as epubcfi_start, + NULL as epubcfi_end, + NULL as note_text, + mn.dedup_key, + mn.last_modified_at, + mn.last_modified_source +FROM media_notes mn +WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = FALSE +ORDER BY created_at DESC; + +-- name: GetTombstonedAnnotationsForBook :many +SELECT + mh.id, + mh.dedup_key, + 'highlight' as annotation_type, + mh.device_sync_data, + mh.deleted_at +FROM media_highlights mh +WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3 +UNION ALL +SELECT + mn.id, + mn.dedup_key, + 'note' as annotation_type, + mn.device_sync_data, + mn.deleted_at +FROM media_notes mn +WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3 +UNION ALL +SELECT + mb.id, + mb.dedup_key, + 'bookmark' as annotation_type, + mb.device_sync_data, + mb.deleted_at +FROM media_bookmarks mb +WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3 +ORDER BY deleted_at DESC; + -- Refresh Tokens queries -- name: CreateRefreshToken :one INSERT INTO refresh_tokens (user_id, token, expires_at) @@ -1126,6 +1371,11 @@ INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data VALUES ($1, $2, $3, $4) RETURNING *; +-- name: CreateAutoResolvedSyncConflict :one +INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at) +VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW()) +RETURNING *; + -- name: GetSyncConflict :one SELECT * FROM sync_conflicts WHERE id = $1; @@ -1225,7 +1475,7 @@ SELECT mh.epubcfi_start, mh.epubcfi_end FROM media_highlights mh -WHERE mh.media_item_id = $1 AND mh.user_id = $2 +WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE UNION ALL SELECT mn.id, @@ -1241,7 +1491,7 @@ SELECT mn.epubcfi_location as epubcfi_start, NULL as epubcfi_end FROM media_notes mn -WHERE mn.media_item_id = $1 AND mn.user_id = $2 +WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE ORDER BY created_at DESC; -- name: UpdateDeviceSyncTimestamp :one @@ -2112,9 +2362,12 @@ RETURNING *; -- name: GetMediaBookmarks :many SELECT * FROM media_bookmarks -WHERE media_item_id = $1 AND user_id = $2 +WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC; +-- name: GetMediaBookmark :one +SELECT * FROM media_bookmarks WHERE id = $1; + -- name: CreateMediaBookmark :one INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) From 3b15766149a834a2d599277cb67793187c71803b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 14:48:47 -0400 Subject: [PATCH 04/52] feat(sync): add AnnotationService with dedup, LWW, and tombstone management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnnotationService is the central service for cross-device annotation sync. It provides SaveHighlight, SaveNote, and SaveBookmark methods that handle the full sync lifecycle: Identity (3-layer): 1. Server UUID (primary key) 2. Per-device native ID stored in device_sync_data JSONB 3. Content dedup_key: sha1(normalize(selection_text) + bucket_position) - CFI character offsets are stripped for bucketing so the same highlight at slightly different offsets still deduplicates - Raw positions are preserved in the DB for precise restoration Resolution policy (LWW): - When the incoming annotation has an explicit ModifiedAt timestamp, last_modified_at wins - When the device sends zero ModifiedAt (creation time only), field-diff mode compares content fields (text/color/note/percentage) — if all match, the save is skipped; if any differ, the save is applied with server-receive-time as the new last_modified_at Conflict detection: - When incoming and existing annotations have different sources (e.g. koreader vs kobo) and content differs, an auto_resolved sync_conflict is recorded with both sides' data for audit trail - Broadcasts a WebSocket conflict notification for real-time UI updates Tombstone management: - Delete-wins: tombstoned annotations block recreation from stale pushes - 30-day TTL before physical purge - PurgeExpiredTombstones method + StartTombstonePurger goroutine (24h ticker) Add locators.go with unified bidirectional CFI conversion: ConvertToCanonical / ConvertFromCanonical - CRE XPointer <-> standard EPUB CFI (for KOReader) - KEPUB CFI passthrough (for Kobo) - Skips non-reflowable formats (PDF, CBZ, fixed-layout EPUBs) Add 25 unit tests covering: - Dedup key determinism, text normalization, position sensitivity - Offset insensitivity (CFI char-offset bucketing) - Device sync data merge (preserves existing, overwrites same source) - Cross-source detection - LWW comparison (newer wins, older skipped, fallback to updated_at) - Field-diff mode (identical content skipped, changes applied) - Tombstone TTL constant - CRE XPointer parsing and classification - Standard EPUB CFI classification --- internal/sync/annotations.go | 754 ++++++++++++++++++++++++++++++ internal/sync/annotations_test.go | 343 ++++++++++++++ internal/sync/locators.go | 133 ++++++ 3 files changed, 1230 insertions(+) create mode 100644 internal/sync/annotations.go create mode 100644 internal/sync/annotations_test.go create mode 100644 internal/sync/locators.go diff --git a/internal/sync/annotations.go b/internal/sync/annotations.go new file mode 100644 index 0000000..d047479 --- /dev/null +++ b/internal/sync/annotations.go @@ -0,0 +1,754 @@ +package sync + +import ( + "bookhoard/internal/database" + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +const TombstoneTTL = 30 * 24 * time.Hour + +type SaveOutcome string + +const ( + SaveOutcomeCreated SaveOutcome = "created" + SaveOutcomeUpdated SaveOutcome = "updated" + SaveOutcomeSkipped SaveOutcome = "skipped" + SaveOutcomeDeleted SaveOutcome = "deleted" +) + +type AnnotationService struct { + db *database.Queries + connMgr *ConnectionManager +} + +func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService { + return &AnnotationService{db: db, connMgr: connMgr} +} + +type SaveHighlightRequest struct { + MediaItemID pgtype.UUID + UserID pgtype.UUID + SelectionText string + StartPosition string + EndPosition string + Color string + NoteText string + PercentageStart float64 + PercentageEnd float64 + EpubcfiStart string + EpubcfiEnd string + ChapterReference int32 + Source string + ModifiedAt time.Time + DeviceSyncData json.RawMessage +} + +type SaveHighlightResult struct { + Highlight database.MediaHighlights + Outcome SaveOutcome + Conflict bool +} + +func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) { + dedupKey := ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition) + + existing, err := s.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{ + UserID: req.UserID, + MediaItemID: req.MediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("query existing highlight: %w", err) + } + + if errors.Is(err, pgx.ErrNoRows) { + return s.createHighlight(ctx, req, dedupKey) + } + + if existing.Deleted.Bool { + if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL { + return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil + } + return s.createHighlight(ctx, req, dedupKey) + } + + return s.applyLWW(ctx, req, existing, dedupKey) +} + +func (s *AnnotationService) createHighlight( + ctx context.Context, + req SaveHighlightRequest, + dedupKey string, +) (*SaveHighlightResult, error) { + modifiedAt := req.ModifiedAt + if modifiedAt.IsZero() { + modifiedAt = time.Now() + } + + deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData) + + highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{ + MediaItemID: req.MediaItemID, + UserID: req.UserID, + SelectionText: req.SelectionText, + StartPosition: pgText(req.StartPosition), + EndPosition: pgText(req.EndPosition), + Color: pgText(req.Color), + NoteText: pgText(req.NoteText), + PercentageStart: pgFloat8(req.PercentageStart), + PercentageEnd: pgFloat8(req.PercentageEnd), + EpubcfiStart: pgText(req.EpubcfiStart), + EpubcfiEnd: pgText(req.EpubcfiEnd), + ChapterReference: pgInt4(req.ChapterReference), + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, + LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, + DeviceSyncData: deviceData, + }) + if err != nil { + return nil, fmt.Errorf("create highlight: %w", err) + } + + s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source) + return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeCreated}, nil +} + +func (s *AnnotationService) applyLWW( + ctx context.Context, + req SaveHighlightRequest, + existing database.MediaHighlights, + dedupKey string, +) (*SaveHighlightResult, error) { + incomingNewer, contentChanged := s.compareIncoming(req, existing) + + if !incomingNewer && !contentChanged { + conflict := isCrossSource(req.Source, existing.LastModifiedSource) + if conflict { + s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight", + existing.DedupKey.String, req.Source, existing.LastModifiedSource.String, + req, existing, "existing") + } + return &SaveHighlightResult{ + Highlight: existing, + Outcome: SaveOutcomeSkipped, + Conflict: conflict, + }, nil + } + + modifiedAt := req.ModifiedAt + if modifiedAt.IsZero() { + modifiedAt = time.Now() + } + + deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData) + + highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{ + ID: existing.ID, + SelectionText: req.SelectionText, + StartPosition: pgText(req.StartPosition), + EndPosition: pgText(req.EndPosition), + Color: pgText(req.Color), + NoteText: pgText(req.NoteText), + PercentageStart: pgFloat8(req.PercentageStart), + PercentageEnd: pgFloat8(req.PercentageEnd), + EpubcfiStart: pgText(req.EpubcfiStart), + EpubcfiEnd: pgText(req.EpubcfiEnd), + ChapterReference: pgInt4(req.ChapterReference), + LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, + LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, + DeviceSyncData: deviceData, + }) + if err != nil { + return nil, fmt.Errorf("update highlight: %w", err) + } + + conflict := isCrossSource(req.Source, existing.LastModifiedSource) + if conflict { + s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight", + existing.DedupKey.String, req.Source, existing.LastModifiedSource.String, + req, existing, "incoming") + } + s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source) + return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil +} + +func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing database.MediaHighlights) (incomingNewer bool, contentChanged bool) { + if req.ModifiedAt.IsZero() { + contentSame := strings.EqualFold(req.SelectionText, existing.SelectionText) && + textEq(req.Color, existing.Color) && + textEq(req.NoteText, existing.NoteText) && + floatEq(req.PercentageStart, existing.PercentageStart) && + floatEq(req.PercentageEnd, existing.PercentageEnd) + return !contentSame, !contentSame + } + + existingMod := existing.LastModifiedAt + if !existingMod.Valid { + existingMod = existing.UpdatedAt + } + return req.ModifiedAt.After(existingMod.Time), true +} + +func (s *AnnotationService) TombstoneHighlight( + ctx context.Context, + userID, mediaItemID pgtype.UUID, + dedupKey string, + source string, +) error { + err := s.db.TombstoneMediaHighlightByDedupKey(ctx, database.TombstoneMediaHighlightByDedupKeyParams{ + UserID: userID, + MediaItemID: mediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil { + return fmt.Errorf("tombstone highlight: %w", err) + } + s.broadcast(pgtype.UUID{}, userID, mediaItemID, "highlight_delete", source) + return nil +} + +func (s *AnnotationService) TombstoneHighlightByID( + ctx context.Context, + highlightID pgtype.UUID, + source string, +) error { + h, err := s.db.GetMediaHighlight(ctx, highlightID) + if err != nil { + return fmt.Errorf("get highlight for tombstone: %w", err) + } + err = s.db.TombstoneMediaHighlightByID(ctx, highlightID) + if err != nil { + return fmt.Errorf("tombstone highlight by ID: %w", err) + } + s.broadcast(pgtype.UUID{}, h.UserID, h.MediaItemID, "highlight_delete", source) + return nil +} + +func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error { + cutoff := pgtype.Timestamptz{Time: time.Now().Add(-TombstoneTTL), Valid: true} + if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil { + return fmt.Errorf("purge highlight tombstones: %w", err) + } + if err := s.db.PurgeExpiredNoteTombstones(ctx, cutoff); err != nil { + return fmt.Errorf("purge note tombstones: %w", err) + } + if err := s.db.PurgeExpiredBookmarkTombstones(ctx, cutoff); err != nil { + return fmt.Errorf("purge bookmark tombstones: %w", err) + } + return nil +} + +func (s *AnnotationService) StartTombstonePurger() context.CancelFunc { + ticker := time.NewTicker(24 * time.Hour) + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + ticker.Stop() + return + case <-ticker.C: + if err := s.PurgeExpiredTombstones(ctx); err != nil { + log.Printf("AnnotationService: tombstone purge failed: %v", err) + } + } + } + }() + + return cancel +} + +type SaveNoteRequest struct { + MediaItemID pgtype.UUID + UserID pgtype.UUID + Content string + Position string + PercentageLocation float64 + CharacterStart int32 + CharacterEnd int32 + EpubcfiLocation string + ChapterReference int32 + ParagraphReference int32 + Source string + ModifiedAt time.Time + DeviceSyncData []byte +} + +type SaveNoteResult struct { + Note database.MediaNotes + Outcome SaveOutcome + Conflict bool +} + +func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (*SaveNoteResult, error) { + if !req.UserID.Valid || !req.MediaItemID.Valid { + return nil, errors.New("invalid user_id or media_item_id") + } + + dedupKey := ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position) + + existing, err := s.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{ + UserID: req.UserID, + MediaItemID: req.MediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("get note by dedup key: %w", err) + } + return s.createNote(ctx, req, dedupKey) + } + + if existing.Deleted.Valid && existing.Deleted.Bool { + return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil + } + + return s.applyNoteLWW(ctx, req, existing, dedupKey) +} + +func (s *AnnotationService) createNote(ctx context.Context, req SaveNoteRequest, dedupKey string) (*SaveNoteResult, error) { + modifiedAt := req.ModifiedAt + if modifiedAt.IsZero() { + modifiedAt = time.Now() + } + + note, err := s.db.CreateMediaNoteFull(ctx, database.CreateMediaNoteFullParams{ + MediaItemID: req.MediaItemID, + UserID: req.UserID, + Content: req.Content, + Position: pgText(req.Position), + PercentageLocation: pgFloat8(req.PercentageLocation), + CharacterStart: pgInt4(req.CharacterStart), + CharacterEnd: pgInt4(req.CharacterEnd), + EpubcfiLocation: pgText(req.EpubcfiLocation), + ChapterReference: pgInt4(req.ChapterReference), + ParagraphReference: pgInt4(req.ParagraphReference), + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, + LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, + DeviceSyncData: req.DeviceSyncData, + }) + if err != nil { + return nil, fmt.Errorf("create note: %w", err) + } + s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source) + return &SaveNoteResult{Note: note, Outcome: SaveOutcomeCreated}, nil +} + +func (s *AnnotationService) applyNoteLWW(ctx context.Context, req SaveNoteRequest, existing database.MediaNotes, dedupKey string) (*SaveNoteResult, error) { + incomingNewer, contentChanged := s.compareIncomingNote(req, existing) + + if !incomingNewer && !contentChanged { + conflict := isCrossSource(req.Source, existing.LastModifiedSource) + if conflict { + s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note", + existing.DedupKey.String, req.Source, existing.LastModifiedSource.String, + req, existing, "existing") + } + return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil + } + + modifiedAt := req.ModifiedAt + if modifiedAt.IsZero() { + modifiedAt = time.Now() + } + + deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData) + + note, err := s.db.UpdateMediaNoteForSync(ctx, database.UpdateMediaNoteForSyncParams{ + ID: existing.ID, + Content: req.Content, + Position: pgText(req.Position), + PercentageLocation: pgFloat8(req.PercentageLocation), + CharacterStart: pgInt4(req.CharacterStart), + CharacterEnd: pgInt4(req.CharacterEnd), + EpubcfiLocation: pgText(req.EpubcfiLocation), + ChapterReference: pgInt4(req.ChapterReference), + ParagraphReference: pgInt4(req.ParagraphReference), + LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, + LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, + DeviceSyncData: deviceData, + }) + if err != nil { + return nil, fmt.Errorf("update note: %w", err) + } + + conflict := isCrossSource(req.Source, existing.LastModifiedSource) + if conflict { + s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note", + existing.DedupKey.String, req.Source, existing.LastModifiedSource.String, + req, existing, "incoming") + } + s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source) + return &SaveNoteResult{Note: note, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil +} + +func (s *AnnotationService) compareIncomingNote(req SaveNoteRequest, existing database.MediaNotes) (incomingNewer bool, contentChanged bool) { + if req.ModifiedAt.IsZero() { + contentSame := strings.EqualFold(req.Content, existing.Content) && + textEq(req.Position, existing.Position) + return !contentSame, !contentSame + } + existingMod := existing.LastModifiedAt + if !existingMod.Valid { + existingMod = existing.UpdatedAt + } + if !existingMod.Valid { + return true, true + } + return req.ModifiedAt.After(existingMod.Time), true +} + +func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id pgtype.UUID) error { + return s.db.TombstoneMediaNoteByID(ctx, id) +} + +type SaveBookmarkRequest struct { + MediaItemID pgtype.UUID + UserID pgtype.UUID + Title string + Position string + Notes string + PageNumber int32 + ChapterNumber int32 + CFIPosition string + PercentageLoc float64 + EpubcfiLocation string + ChapterReference int32 + Source string + ModifiedAt time.Time + DeviceSyncData json.RawMessage +} + +type SaveBookmarkResult struct { + Bookmark database.MediaBookmarks + Outcome SaveOutcome + Conflict bool +} + +func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) { + dedupKey := ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position) + + existing, err := s.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{ + UserID: req.UserID, + MediaItemID: req.MediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("query existing bookmark: %w", err) + } + + if errors.Is(err, pgx.ErrNoRows) { + return s.createBookmark(ctx, req, dedupKey) + } + + if existing.Deleted.Bool { + if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL { + return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil + } + return s.createBookmark(ctx, req, dedupKey) + } + + return s.applyBookmarkLWW(ctx, req, existing, dedupKey) +} + +func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmarkRequest, dedupKey string) (*SaveBookmarkResult, error) { + modifiedAt := req.ModifiedAt + if modifiedAt.IsZero() { + modifiedAt = time.Now() + } + deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData) + + bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{ + MediaItemID: req.MediaItemID, + UserID: req.UserID, + PageNumber: pgInt4(req.PageNumber), + ChapterNumber: pgInt4(req.ChapterNumber), + CfiPosition: pgText(req.CFIPosition), + Title: req.Title, + Position: pgText(req.Position), + Notes: pgText(req.Notes), + PercentageLocation: pgFloat8(req.PercentageLoc), + EpubcfiLocation: pgText(req.EpubcfiLocation), + ChapterReference: pgInt4(req.ChapterReference), + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, + LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, + DeviceSyncData: deviceData, + }) + if err != nil { + return nil, fmt.Errorf("create bookmark: %w", err) + } + s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source) + return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeCreated}, nil +} + +func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookmarkRequest, existing database.MediaBookmarks, dedupKey string) (*SaveBookmarkResult, error) { + incomingNewer, contentChanged := s.compareIncomingBookmark(req, existing) + + if !incomingNewer && !contentChanged { + conflict := isCrossSource(req.Source, existing.LastModifiedSource) + if conflict { + s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark", + existing.DedupKey.String, req.Source, existing.LastModifiedSource.String, + req, existing, "existing") + } + return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil + } + + modifiedAt := req.ModifiedAt + if modifiedAt.IsZero() { + modifiedAt = time.Now() + } + deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData) + + bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{ + ID: existing.ID, + PageNumber: pgInt4(req.PageNumber), + ChapterNumber: pgInt4(req.ChapterNumber), + CfiPosition: pgText(req.CFIPosition), + Title: req.Title, + Position: pgText(req.Position), + Notes: pgText(req.Notes), + PercentageLocation: pgFloat8(req.PercentageLoc), + EpubcfiLocation: pgText(req.EpubcfiLocation), + ChapterReference: pgInt4(req.ChapterReference), + LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, + LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, + DeviceSyncData: deviceData, + }) + if err != nil { + return nil, fmt.Errorf("update bookmark: %w", err) + } + conflict := isCrossSource(req.Source, existing.LastModifiedSource) + if conflict { + s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark", + existing.DedupKey.String, req.Source, existing.LastModifiedSource.String, + req, existing, "incoming") + } + s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source) + return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil +} + +func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) { + if req.ModifiedAt.IsZero() { + contentSame := strings.EqualFold(req.Title, existing.Title) && + textEq(req.Notes, existing.Notes) + return !contentSame, !contentSame + } + existingMod := existing.LastModifiedAt + if !existingMod.Valid { + existingMod = existing.CreatedAt + } + return req.ModifiedAt.After(existingMod.Time), true +} + +func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, bookmarkID pgtype.UUID, source string) error { + bm, err := s.db.GetMediaBookmark(ctx, bookmarkID) + if err != nil { + return fmt.Errorf("get bookmark for tombstone: %w", err) + } + err = s.db.TombstoneMediaBookmarkByID(ctx, bookmarkID) + if err != nil { + return fmt.Errorf("tombstone bookmark by ID: %w", err) + } + s.broadcast(pgtype.UUID{}, bm.UserID, bm.MediaItemID, "bookmark_delete", source) + return nil +} + +func (s *AnnotationService) recordConflict( + ctx context.Context, + userID, mediaItemID pgtype.UUID, + conflictType, dedupKey string, + incomingSource, existingSource string, + incoming any, + existing any, + winner string, +) { + if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid { + return + } + + incomingJSON, _ := json.Marshal(incoming) + existingJSON, _ := json.Marshal(existing) + + var incomingMap, existingMap map[string]interface{} + json.Unmarshal(incomingJSON, &incomingMap) + json.Unmarshal(existingJSON, &existingMap) + if incomingMap == nil { + incomingMap = map[string]interface{}{} + } + if existingMap == nil { + existingMap = map[string]interface{}{} + } + incomingMap["dedup_key"] = dedupKey + existingMap["dedup_key"] = dedupKey + + conflictData, _ := json.Marshal(map[string]interface{}{ + "incoming": map[string]interface{}{ + "source": incomingSource, + "data": incomingMap, + }, + "existing": map[string]interface{}{ + "source": existingSource, + "data": existingMap, + }, + }) + + resolutionData, _ := json.Marshal(map[string]interface{}{ + "winner": winner, + "reason": "last_modified_at_wins", + }) + + conflict, err := s.db.CreateAutoResolvedSyncConflict(ctx, database.CreateAutoResolvedSyncConflictParams{ + MediaItemID: mediaItemID, + UserID: userID, + ConflictType: conflictType, + ConflictData: conflictData, + ResolutionData: resolutionData, + }) + if err != nil { + log.Printf("AnnotationService: failed to record conflict: %v", err) + return + } + + var conflictIDStr string + if conflict.ID.Valid { + conflictIDStr = uuid.UUID(conflict.ID.Bytes).String() + } + s.connMgr.BroadcastConflictNotification( + uuid.UUID(mediaItemID.Bytes), + "annotation_conflict", + conflictIDStr, + ) +} + +func (s *AnnotationService) broadcast( + highlightID, userID, mediaItemID pgtype.UUID, + annotationType string, + source string, +) { + if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid { + return + } + src := SourceDevice{Type: source} + s.connMgr.BroadcastAnnotationUpdate( + uuid.UUID(mediaItemID.Bytes), + annotationType, + map[string]interface{}{ + "highlight_id": uuid.UUID(highlightID.Bytes), + }, + src, + ) +} + +func ComputeDedupKey(selectionText, epubcfiStart, startPosition string) string { + normalized := normalizeText(selectionText) + posBucket := bucketPosition(epubcfiStart) + if posBucket == "" { + posBucket = bucketPosition(startPosition) + } + + h := sha1.New() + h.Write([]byte(normalized)) + h.Write([]byte{0}) + h.Write([]byte(posBucket)) + return hex.EncodeToString(h.Sum(nil)) +} + +func normalizeText(s string) string { + fields := strings.Fields(strings.ToLower(s)) + return strings.Join(fields, " ") +} + +func bucketPosition(pos string) string { + if pos == "" { + return "" + } + if strings.HasPrefix(pos, "epubcfi(") { + if idx := strings.LastIndex(pos, ":"); idx > 0 { + return pos[:idx] + } + } + if len(pos) > 50 { + return pos[:50] + } + return pos +} + +func mergeDeviceSyncData(existing []byte, source string, data json.RawMessage) []byte { + if source == "" && len(data) == 0 { + return existing + } + m := make(map[string]interface{}) + if len(existing) > 0 { + _ = json.Unmarshal(existing, &m) + } + if source != "" { + if len(data) > 0 { + var val interface{} + _ = json.Unmarshal(data, &val) + m[source] = val + } else { + m[source] = map[string]interface{}{"synced_at": time.Now().UTC().Format(time.RFC3339)} + } + } + result, _ := json.Marshal(m) + return result +} + +func isCrossSource(incoming string, existing pgtype.Text) bool { + if incoming == "" || !existing.Valid { + return false + } + return incoming != existing.String +} + +func pgText(s string) pgtype.Text { + if s == "" { + return pgtype.Text{Valid: false} + } + return pgtype.Text{String: s, Valid: true} +} + +func pgFloat8(f float64) pgtype.Float8 { + if f == 0 { + return pgtype.Float8{Valid: false} + } + return pgtype.Float8{Float64: f, Valid: true} +} + +func pgInt4(i int32) pgtype.Int4 { + if i == 0 { + return pgtype.Int4{Valid: false} + } + return pgtype.Int4{Int32: i, Valid: true} +} + +func textEq(a string, b pgtype.Text) bool { + if !b.Valid { + return a == "" + } + return a == b.String +} + +func floatEq(a float64, b pgtype.Float8) bool { + if !b.Valid { + return a == 0 + } + return math.Abs(a-b.Float64) < 0.001 +} diff --git a/internal/sync/annotations_test.go b/internal/sync/annotations_test.go new file mode 100644 index 0000000..bf0adde --- /dev/null +++ b/internal/sync/annotations_test.go @@ -0,0 +1,343 @@ +package sync + +import ( + "bookhoard/internal/database" + "encoding/json" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestComputeDedupKey_Deterministic(t *testing.T) { + k1 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "") + k2 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "") + if k1 != k2 { + t.Errorf("same input should produce same key: %q vs %q", k1, k2) + } +} + +func TestComputeDedupKey_Normalization(t *testing.T) { + cases := [][]string{ + {"Hello World", " Hello World "}, + {"HELLO WORLD", "hello world"}, + {"Hello World", "Hello World"}, + {"Hello\t\nWorld", "Hello World"}, + } + cfi := "epubcfi(/6/4!/4/10/3:100)" + for _, c := range cases { + k1 := ComputeDedupKey(c[0], cfi, "") + k2 := ComputeDedupKey(c[1], cfi, "") + if k1 != k2 { + t.Errorf("normalized texts should match: %q vs %q → %q vs %q", c[0], c[1], k1, k2) + } + } +} + +func TestComputeDedupKey_PositionSensitivity(t *testing.T) { + text := "same text" + k1 := ComputeDedupKey(text, "epubcfi(/6/4!/4/10/3:100)", "") + k2 := ComputeDedupKey(text, "epubcfi(/6/4!/4/20/3:100)", "") + if k1 == k2 { + t.Error("different element paths should produce different keys") + } +} + +func TestComputeDedupKey_OffsetInsensitive(t *testing.T) { + text := "same text" + base := "epubcfi(/6/4!/4/10/3:100)" + offsetShift := "epubcfi(/6/4!/4/10/3:200)" + k1 := ComputeDedupKey(text, base, "") + k2 := ComputeDedupKey(text, offsetShift, "") + if k1 != k2 { + t.Error("same element path with different char offsets should produce same key (bucket)") + } +} + +func TestComputeDedupKey_FallbackToRawPosition(t *testing.T) { + text := "same text" + k1 := ComputeDedupKey(text, "", "page:42") + k2 := ComputeDedupKey(text, "", "page:42") + if k1 != k2 { + t.Error("same raw position should produce same key") + } + k3 := ComputeDedupKey(text, "", "page:99") + if k1 == k3 { + t.Error("different raw positions should produce different keys") + } +} + +func TestComputeDedupKey_DifferentTextSamePosition(t *testing.T) { + cfi := "epubcfi(/6/4!/4/10/3:100)" + k1 := ComputeDedupKey("first highlight", cfi, "") + k2 := ComputeDedupKey("second highlight", cfi, "") + if k1 == k2 { + t.Error("different selection text should produce different keys") + } +} + +func TestNormalizeText(t *testing.T) { + cases := []struct{ in, want string }{ + {"Hello World", "hello world"}, + {" Hello World ", "hello world"}, + {"Hello\t\nWorld", "hello world"}, + {"", ""}, + {" ", ""}, + } + for _, c := range cases { + got := normalizeText(c.in) + if got != c.want { + t.Errorf("normalizeText(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestBucketPosition(t *testing.T) { + cases := []struct{ in, want string }{ + {"epubcfi(/6/4!/4/10/3:100)", "epubcfi(/6/4!/4/10/3"}, + {"epubcfi(/6/4!/4/10/3:0)", "epubcfi(/6/4!/4/10/3"}, + {"page:42", "page:42"}, + {"short", "short"}, + {"", ""}, + } + for _, c := range cases { + got := bucketPosition(c.in) + if got != c.want { + t.Errorf("bucketPosition(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestBucketPosition_LongString(t *testing.T) { + long := "this_is_a_very_long_position_string_that_exceeds_fifty_characters_total" + got := bucketPosition(long) + if len(got) > 50 { + t.Errorf("bucketPosition should truncate to <=50 chars, got %d", len(got)) + } + if got != long[:50] { + t.Errorf("bucketPosition truncated wrong: got %q", got) + } +} + +func TestMergeDeviceSyncData_NewEntry(t *testing.T) { + result := mergeDeviceSyncData(nil, "koreader", json.RawMessage(`{"datetime":"2024-01-01"}`)) + var m map[string]interface{} + if err := json.Unmarshal(result, &m); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + entry, ok := m["koreader"] + if !ok { + t.Fatal("expected koreader entry") + } + entryMap := entry.(map[string]interface{}) + if entryMap["datetime"] != "2024-01-01" { + t.Errorf("unexpected datetime: %v", entryMap["datetime"]) + } +} + +func TestMergeDeviceSyncData_PreservesExisting(t *testing.T) { + existing := []byte(`{"koreader":{"datetime":"2024-01-01"}}`) + result := mergeDeviceSyncData(existing, "kobo", json.RawMessage(`{"bookmark_id":"abc"}`)) + var m map[string]interface{} + if err := json.Unmarshal(result, &m); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if _, ok := m["koreader"]; !ok { + t.Error("koreader entry should be preserved") + } + if _, ok := m["kobo"]; !ok { + t.Error("kobo entry should be added") + } +} + +func TestMergeDeviceSyncData_OverwritesSameSource(t *testing.T) { + existing := []byte(`{"koreader":{"datetime":"old"}}`) + result := mergeDeviceSyncData(existing, "koreader", json.RawMessage(`{"datetime":"new"}`)) + var m map[string]interface{} + json.Unmarshal(result, &m) + entry := m["koreader"].(map[string]interface{}) + if entry["datetime"] != "new" { + t.Errorf("expected overwritten datetime 'new', got %v", entry["datetime"]) + } +} + +func TestIsCrossSource(t *testing.T) { + if isCrossSource("koreader", pgtype.Text{String: "kobo", Valid: true}) != true { + t.Error("different sources should be cross-source") + } + if isCrossSource("koreader", pgtype.Text{String: "koreader", Valid: true}) != false { + t.Error("same sources should not be cross-source") + } + if isCrossSource("", pgtype.Text{String: "koreader", Valid: true}) != false { + t.Error("empty incoming source should not be cross-source") + } + if isCrossSource("koreader", pgtype.Text{Valid: false}) != false { + t.Error("invalid existing source should not be cross-source") + } +} + +func TestCompareIncoming_FieldDiff_Identical(t *testing.T) { + svc := &AnnotationService{} + req := SaveHighlightRequest{ + SelectionText: "hello", + Color: "#ffff00", + NoteText: "a note", + PercentageStart: 10.5, + PercentageEnd: 11.0, + } + existing := pgHighlights("hello", "#ffff00", "a note", 10.5, 11.0) + newer, changed := svc.compareIncoming(req, existing) + if newer { + t.Error("identical content should not be newer") + } + if changed { + t.Error("identical content should not be changed") + } +} + +func TestCompareIncoming_FieldDiff_DifferentText(t *testing.T) { + svc := &AnnotationService{} + req := SaveHighlightRequest{ + SelectionText: "edited text", + } + existing := pgHighlights("original text", "#ffff00", "", 0, 0) + newer, changed := svc.compareIncoming(req, existing) + if !newer { + t.Error("different content should be newer") + } + if !changed { + t.Error("different content should be changed") + } +} + +func TestCompareIncoming_FieldDiff_DifferentColor(t *testing.T) { + svc := &AnnotationService{} + req := SaveHighlightRequest{ + SelectionText: "same", + Color: "#ff0000", + } + existing := pgHighlights("same", "#ffff00", "", 0, 0) + _, changed := svc.compareIncoming(req, existing) + if !changed { + t.Error("different color should be detected as changed") + } +} + +func TestCompareIncoming_LWW_NewerWins(t *testing.T) { + svc := &AnnotationService{} + now := time.Now() + req := SaveHighlightRequest{ + SelectionText: "same", + ModifiedAt: now.Add(1 * time.Hour), + } + existing := pgHighlights("same", "", "", 0, 0) + existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true} + newer, changed := svc.compareIncoming(req, existing) + if !newer { + t.Error("future timestamp should be newer") + } + if !changed { + t.Error("LWW mode should always report changed=true") + } +} + +func TestCompareIncoming_LWW_OlderSkipped(t *testing.T) { + svc := &AnnotationService{} + now := time.Now() + req := SaveHighlightRequest{ + SelectionText: "same", + ModifiedAt: now.Add(-1 * time.Hour), + } + existing := pgHighlights("same", "", "", 0, 0) + existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true} + newer, _ := svc.compareIncoming(req, existing) + if newer { + t.Error("past timestamp should not be newer") + } +} + +func TestCompareIncoming_LWW_FallsBackToUpdatedAt(t *testing.T) { + svc := &AnnotationService{} + now := time.Now() + req := SaveHighlightRequest{ + SelectionText: "same", + ModifiedAt: now.Add(1 * time.Hour), + } + existing := pgHighlights("same", "", "", 0, 0) + existing.LastModifiedAt = pgtype.Timestamptz{Valid: false} + existing.UpdatedAt = pgtype.Timestamptz{Time: now, Valid: true} + newer, _ := svc.compareIncoming(req, existing) + if !newer { + t.Error("should fall back to updated_at when last_modified_at is invalid") + } +} + +func TestPgText(t *testing.T) { + if pgText("").Valid { + t.Error("empty string should produce invalid pgtype.Text") + } + v := pgText("hello") + if !v.Valid || v.String != "hello" { + t.Errorf("expected valid 'hello', got %+v", v) + } +} + +func TestPgFloat8(t *testing.T) { + if pgFloat8(0).Valid { + t.Error("zero should produce invalid pgtype.Float8") + } + v := pgFloat8(1.5) + if !v.Valid || v.Float64 != 1.5 { + t.Errorf("expected valid 1.5, got %+v", v) + } +} + +func TestPgInt4(t *testing.T) { + if pgInt4(0).Valid { + t.Error("zero should produce invalid pgtype.Int4") + } + v := pgInt4(3) + if !v.Valid || v.Int32 != 3 { + t.Errorf("expected valid 3, got %+v", v) + } +} + +func TestFloatEq(t *testing.T) { + if !floatEq(0, pgtype.Float8{Valid: false}) { + t.Error("0 vs invalid should be equal") + } + if !floatEq(10.5, pgtype.Float8{Float64: 10.5, Valid: true}) { + t.Error("10.5 vs 10.5 should be equal") + } + if floatEq(10.6, pgtype.Float8{Float64: 10.5, Valid: true}) { + t.Error("10.6 vs 10.5 should not be equal") + } +} + +func TestTextEq(t *testing.T) { + if !textEq("", pgtype.Text{Valid: false}) { + t.Error("empty vs invalid should be equal") + } + if !textEq("hi", pgtype.Text{String: "hi", Valid: true}) { + t.Error("same strings should be equal") + } + if textEq("hi", pgtype.Text{String: "bye", Valid: true}) { + t.Error("different strings should not be equal") + } +} + +func TestTombstoneTTL(t *testing.T) { + if TombstoneTTL != 30*24*time.Hour { + t.Errorf("expected 30 days, got %v", TombstoneTTL) + } +} + +func pgHighlights(text, color, note string, pctStart, pctEnd float64) database.MediaHighlights { + return database.MediaHighlights{ + SelectionText: text, + Color: pgtype.Text{String: color, Valid: color != ""}, + NoteText: pgtype.Text{String: note, Valid: note != ""}, + PercentageStart: pgtype.Float8{Float64: pctStart, Valid: pctStart != 0}, + PercentageEnd: pgtype.Float8{Float64: pctEnd, Valid: pctEnd != 0}, + } +} diff --git a/internal/sync/locators.go b/internal/sync/locators.go new file mode 100644 index 0000000..2a800ea --- /dev/null +++ b/internal/sync/locators.go @@ -0,0 +1,133 @@ +package sync + +import "log" + +type LocatorSource string + +const ( + LocatorSourceKOReader LocatorSource = "koreader" + LocatorSourceKobo LocatorSource = "kobo" + LocatorSourceWeb LocatorSource = "web" +) + +type CanonicalLocator struct { + CFI string + Precision string + Percentage float64 +} + +type DeviceLocator struct { + Position string + Precision string + Percentage float64 +} + +func isConvertible(formatGroup string) bool { + return formatGroup == string(FormatGroupReflowable) +} + +func ConvertToCanonical( + source LocatorSource, + devicePos string, + percentage float64, + contextText string, + formatGroup string, + epubPath string, + kepubPath string, +) CanonicalLocator { + if !isConvertible(formatGroup) || epubPath == "" { + return CanonicalLocator{ + CFI: devicePos, + Precision: "passthrough", + Percentage: percentage, + } + } + + switch source { + case LocatorSourceKOReader: + if !IsCREXPointer(devicePos) { + return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage} + } + converter := NewCFIConverter(epubPath) + result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText) + if err != nil || result == nil { + log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err) + return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage} + } + if result.EPUBCFI != "" { + return CanonicalLocator{CFI: result.EPUBCFI, Precision: result.Precision, Percentage: result.Percentage} + } + if result.Href != "" { + return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage} + } + return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage} + + case LocatorSourceKobo: + if kepubPath == "" { + return CanonicalLocator{CFI: devicePos, Precision: "no-kepub", Percentage: percentage} + } + converter := NewKEPUBCFIConverter(epubPath, kepubPath) + result, err := converter.ConvertKEPUBCFIToStandard(devicePos, percentage, contextText) + if err != nil || result == nil { + log.Printf("Bookhoard: locator KEPUB→CFI conversion failed: %v", err) + return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage} + } + if result.CFI != "" { + return CanonicalLocator{CFI: result.CFI, Precision: result.Precision, Percentage: result.Percentage} + } + return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage} + + default: + return CanonicalLocator{CFI: devicePos, Precision: "passthrough", Percentage: percentage} + } +} + +func ConvertFromCanonical( + source LocatorSource, + canonicalCFI string, + percentage float64, + contextText string, + formatGroup string, + epubPath string, + kepubPath string, +) DeviceLocator { + if !isConvertible(formatGroup) || epubPath == "" || canonicalCFI == "" { + return DeviceLocator{ + Position: canonicalCFI, + Precision: "passthrough", + Percentage: percentage, + } + } + + switch source { + case LocatorSourceKOReader: + converter := NewCFIConverter(epubPath) + result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText) + if err != nil || result == nil { + log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err) + return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage} + } + if result.XPointer != "" { + return DeviceLocator{Position: result.XPointer, Precision: result.Precision, Percentage: result.Percentage} + } + return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage} + + case LocatorSourceKobo: + if kepubPath == "" { + return DeviceLocator{Position: canonicalCFI, Precision: "no-kepub", Percentage: percentage} + } + converter := NewKEPUBCFIConverter(epubPath, kepubPath) + result, err := converter.ConvertStandardCFIToKEPUB(canonicalCFI, percentage, contextText) + if err != nil || result == nil { + log.Printf("Bookhoard: locator CFI→KEPUB conversion failed: %v", err) + return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage} + } + if result.CFI != "" { + return DeviceLocator{Position: result.CFI, Precision: result.Precision, Percentage: result.Percentage} + } + return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage} + + default: + return DeviceLocator{Position: canonicalCFI, Precision: "passthrough", Percentage: percentage} + } +} From 635a9439cbb4e65a55b32151a2f4208a98f187a5 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 14:49:01 -0400 Subject: [PATCH 05/52] feat(sync): implement annotation support in sync queue processor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire AnnotationService into SyncQueueProcessor and implement the three previously-stubbed execute methods: - syncHighlight: unmarshals syncData JSON into SaveHighlightRequest, applies CRE→CFI conversion via AnnotationService - syncNote: unmarshals into SaveNoteRequest - syncBookmark: unmarshals into SaveBookmarkRequest - Add SyncTypeBookmark to executeSync switch (was hitting default error) Add enqueue methods for future offline/batch use: - EnqueueHighlight / EnqueueNote / EnqueueBookmark - Shared enqueueAnnotation helper creates queue items with PriorityCriticalNote and 3 max attempts - Update types (HighlightUpdate, NoteUpdate, BookmarkUpdate) mirror the existing ProgressUpdate pattern Existing handler behavior is unchanged — annotations still sync synchronously via AnnotationService. The queue path is available for retry-on-failure and offline batch processing scenarios. --- internal/sync/queue.go | 242 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 235 insertions(+), 7 deletions(-) diff --git a/internal/sync/queue.go b/internal/sync/queue.go index e4ad5d9..b7822e0 100644 --- a/internal/sync/queue.go +++ b/internal/sync/queue.go @@ -35,11 +35,12 @@ const ( ) type SyncQueueProcessor struct { - db *database.Queries - progressSvc *ProgressService - progressChan chan *ProgressUpdate - interval time.Duration - batchSize int + db *database.Queries + progressSvc *ProgressService + annotationSvc *AnnotationService + progressChan chan *ProgressUpdate + interval time.Duration + batchSize int } type ProgressUpdate struct { @@ -85,6 +86,10 @@ func (p *SyncQueueProcessor) SetProgressService(svc *ProgressService) { p.progressSvc = svc } +func (p *SyncQueueProcessor) SetAnnotationService(svc *AnnotationService) { + p.annotationSvc = svc +} + func (p *SyncQueueProcessor) Start(ctx context.Context) { log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize) @@ -113,6 +118,100 @@ func (p *SyncQueueProcessor) EnqueueProgress(update *ProgressUpdate) error { } } +type HighlightUpdate struct { + DeviceID pgtype.UUID + MediaItemID pgtype.UUID + UserID pgtype.UUID + SelectionText string + StartPosition string + EndPosition string + Color string + NoteText string + EpubcfiStart string + EpubcfiEnd string + PercentageStart float64 + PercentageEnd float64 + Source string + DeviceSyncData map[string]interface{} +} + +type NoteUpdate struct { + DeviceID pgtype.UUID + MediaItemID pgtype.UUID + UserID pgtype.UUID + Content string + Position string + Source string + DeviceSyncData map[string]interface{} +} + +type BookmarkUpdate struct { + DeviceID pgtype.UUID + MediaItemID pgtype.UUID + UserID pgtype.UUID + Title string + Position string + Notes string + Source string + DeviceSyncData map[string]interface{} +} + +func (p *SyncQueueProcessor) EnqueueHighlight(ctx context.Context, update *HighlightUpdate) error { + syncData := map[string]interface{}{ + "selection_text": update.SelectionText, + "start_position": update.StartPosition, + "end_position": update.EndPosition, + "color": update.Color, + "note_text": update.NoteText, + "source": update.Source, + "epubcfi_start": update.EpubcfiStart, + "epubcfi_end": update.EpubcfiEnd, + "percentage_start": update.PercentageStart, + "percentage_end": update.PercentageEnd, + "device_sync_data": update.DeviceSyncData, + } + return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeHighlight, syncData) +} + +func (p *SyncQueueProcessor) EnqueueNote(ctx context.Context, update *NoteUpdate) error { + syncData := map[string]interface{}{ + "content": update.Content, + "position": update.Position, + "source": update.Source, + "device_sync_data": update.DeviceSyncData, + } + return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeNote, syncData) +} + +func (p *SyncQueueProcessor) EnqueueBookmark(ctx context.Context, update *BookmarkUpdate) error { + syncData := map[string]interface{}{ + "title": update.Title, + "position": update.Position, + "notes": update.Notes, + "source": update.Source, + "device_sync_data": update.DeviceSyncData, + } + return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeBookmark, syncData) +} + +func (p *SyncQueueProcessor) enqueueAnnotation(ctx context.Context, deviceID, mediaItemID pgtype.UUID, syncType string, syncData map[string]interface{}) error { + syncDataJSON, err := json.Marshal(syncData) + if err != nil { + return fmt.Errorf("marshal sync data: %w", err) + } + + _, err = p.db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{ + DeviceID: deviceID, + MediaItemID: mediaItemID, + SyncType: syncType, + SyncData: syncDataJSON, + Priority: pgtype.Int4{Int32: int32(PriorityCriticalNote), Valid: true}, + MaxAttempts: pgtype.Int4{Int32: 3, Valid: true}, + Status: pgtype.Text{String: SyncStatusPending, Valid: true}, + }) + return err +} + func (p *SyncQueueProcessor) enqueueProgressUpdate(ctx context.Context, update *ProgressUpdate) { syncData := map[string]interface{}{ "percentage": update.Percentage, @@ -308,6 +407,8 @@ func (p *SyncQueueProcessor) executeSync(ctx context.Context, item SyncQueueItem return p.syncNote(ctx, device.UserID, item.MediaItemID, syncData) case SyncTypeHighlight: return p.syncHighlight(ctx, device.UserID, item.MediaItemID, syncData) + case SyncTypeBookmark: + return p.syncBookmark(ctx, device.UserID, item.MediaItemID, syncData) default: return fmt.Errorf("unsupported sync type: %s", item.SyncType) } @@ -407,11 +508,138 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI } func (p *SyncQueueProcessor) syncNote(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error { - return fmt.Errorf("note sync not yet implemented") + if p.annotationSvc == nil { + return fmt.Errorf("annotation service not available") + } + + req := SaveNoteRequest{ + MediaItemID: mediaItemID, + UserID: userID, + } + + if v, ok := syncData["content"].(string); ok { + req.Content = v + } + if v, ok := syncData["position"].(string); ok { + req.Position = v + } + if v, ok := syncData["source"].(string); ok { + req.Source = v + } + if v, ok := syncData["epubcfi_location"].(string); ok { + req.EpubcfiLocation = v + } + if v, ok := syncData["percentage_location"].(float64); ok { + req.PercentageLocation = v + } + if v, ok := syncData["chapter_reference"].(float64); ok { + req.ChapterReference = int32(v) + } + if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok { + req.DeviceSyncData, _ = json.Marshal(v) + } + + _, err := p.annotationSvc.SaveNote(ctx, req) + return err } func (p *SyncQueueProcessor) syncHighlight(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error { - return fmt.Errorf("highlight sync not yet implemented") + if p.annotationSvc == nil { + return fmt.Errorf("annotation service not available") + } + + req := SaveHighlightRequest{ + MediaItemID: mediaItemID, + UserID: userID, + } + + if v, ok := syncData["selection_text"].(string); ok { + req.SelectionText = v + } + if v, ok := syncData["start_position"].(string); ok { + req.StartPosition = v + } + if v, ok := syncData["end_position"].(string); ok { + req.EndPosition = v + } + if v, ok := syncData["color"].(string); ok { + req.Color = v + } + if v, ok := syncData["note_text"].(string); ok { + req.NoteText = v + } + if v, ok := syncData["source"].(string); ok { + req.Source = v + } + if v, ok := syncData["epubcfi_start"].(string); ok { + req.EpubcfiStart = v + } + if v, ok := syncData["epubcfi_end"].(string); ok { + req.EpubcfiEnd = v + } + if v, ok := syncData["percentage_start"].(float64); ok { + req.PercentageStart = v + } + if v, ok := syncData["percentage_end"].(float64); ok { + req.PercentageEnd = v + } + if v, ok := syncData["chapter_reference"].(float64); ok { + req.ChapterReference = int32(v) + } + if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok { + req.DeviceSyncData, _ = json.Marshal(v) + } + + _, err := p.annotationSvc.SaveHighlight(ctx, req) + return err +} + +func (p *SyncQueueProcessor) syncBookmark(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error { + if p.annotationSvc == nil { + return fmt.Errorf("annotation service not available") + } + + req := SaveBookmarkRequest{ + MediaItemID: mediaItemID, + UserID: userID, + } + + if v, ok := syncData["title"].(string); ok { + req.Title = v + } + if v, ok := syncData["position"].(string); ok { + req.Position = v + } + if v, ok := syncData["notes"].(string); ok { + req.Notes = v + } + if v, ok := syncData["source"].(string); ok { + req.Source = v + } + if v, ok := syncData["cfi_position"].(string); ok { + req.CFIPosition = v + } + if v, ok := syncData["epubcfi_location"].(string); ok { + req.EpubcfiLocation = v + } + if v, ok := syncData["percentage_loc"].(float64); ok { + req.PercentageLoc = v + } + if v, ok := syncData["page_number"].(float64); ok { + req.PageNumber = int32(v) + } + if v, ok := syncData["chapter_number"].(float64); ok { + req.ChapterNumber = int32(v) + } + if v, ok := syncData["chapter_reference"].(float64); ok { + req.ChapterReference = int32(v) + } + if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok { + req.DeviceSyncData, _ = json.Marshal(v) + } + + _, err := p.annotationSvc.SaveBookmark(ctx, req) + return err } func (p *SyncQueueProcessor) markItemFailed(ctx context.Context, item SyncQueueItem, errMsg string) { From 75b33fdae6a993f0ceaa8ddc3c75d3d7ca94db7c Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 14:49:19 -0400 Subject: [PATCH 06/52] feat(sync): wire annotation sync into all device and web handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the annotation sync pipeline across all ingest and serve paths. Previously, annotations sent inline with KOReader progress pushes were silently discarded, and no annotations were ever served back to devices. INGEST (device → server): KOReader (koreader.go): - Add processBookAnnotations helper that processes inline highlights, notes, and bookmarks from every progress push (immediate + checkpoint) - Highlights get CRE→CFI position conversion before SaveHighlight - KOReader 'notes' (text + notes) stored as highlights with NoteText to ensure correct round-trip classification - Bookmarks routed through SaveBookmark with device sync data - Called from both updateProgressForBook and handleCheckpointSync Kobo (kobo.go): - Markup handler: annotations and bookmarks route through AnnotationService (SaveHighlight/SaveBookmark) - Bookmark handler: same routing with device sync data - SyncFromServer handler: same routing - All handlers fall back to direct DB calls when annotationSvc == nil Web reader (media.go): - CreateMediaHighlight → SaveHighlight (Source="web", ModifiedAt=now) - CreateMediaNote → SaveNote (Source="web") - DeleteMediaHighlight → TombstoneHighlightByID - DeleteMediaNote → TombstoneNoteByID (was hard delete, now tombstone) - All fall back to old behavior when annotationSvc == nil SERVE (server → device): KOReader GetMetadata (koreader.go): - Query and serve bookmarks from media_bookmarks table (was missing) - Serve deleted_highlights and deleted_bookmarks arrays containing device_sync_data + dedup_key for client-side deletion - Highlights/notes already served with reverse CFI conversion Kobo Markup handler (kobo.go): - Track processed books during sync - Query tombstones per book, extract bookmark_id from device_sync_data - Return DeletedAnnotations array in KoboSyncStatus response Conflict resolution (conflicts.go): - Enable annotation conflict types in ResolveConflict handler - Add applyAnnotationResolution dispatching to: applyHighlightResolution / applyBookmarkResolution / applyNoteResolution - Each looks up by dedup_key and applies winner's fields - Allow manual override of auto_resolved conflicts (changed check from != "unresolved" to == "user_resolved") Infrastructure: - AnnotationService field + SetAnnotationService in router Config - Inject AnnotationService into KOReader, Kobo, Media handlers - Start tombstone purger goroutine in main.go (24h interval) - Test helpers: construct AnnotationService in test setup --- cmd/server/main.go | 7 + cmd/server/tests/test_helpers_test.go | 5 + internal/handlers/conflicts.go | 145 ++++++++++- internal/handlers/kobo.go | 291 ++++++++++++++++----- internal/handlers/koreader.go | 352 +++++++++++++++++++++++--- internal/handlers/media.go | 80 +++++- internal/router/router.go | 1 + internal/router/sync.go | 1 + 8 files changed, 772 insertions(+), 110 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index ea041a3..6ccd173 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -63,9 +63,13 @@ func main() { connManager := sync.NewConnectionManager() progressService := sync.NewProgressService(queries, connManager) + annotationService := sync.NewAnnotationService(queries, connManager) + tombstonePurgerCancel := annotationService.StartTombstonePurger() + defer tombstonePurgerCancel() queueProcessor := sync.NewSyncQueueProcessor(queries) queueProcessor.SetProgressService(progressService) + queueProcessor.SetAnnotationService(annotationService) // Create library service libraryService := services.NewLibraryService(queries) @@ -79,6 +83,7 @@ func main() { koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) koreaderHandler.SetProgressService(progressService) + koreaderHandler.SetAnnotationService(annotationService) koreaderHandler.SetLibraryService(libraryService) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) conflictHandler := handlers.NewConflictHandler(queries, connManager) @@ -95,6 +100,7 @@ func main() { filtersHandler := handlers.NewFiltersHandler(queries) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) mediaHandler.SetProgressService(progressService) + mediaHandler.SetAnnotationService(annotationService) matchingHandler := handlers.NewMatchingHandler(queries, connManager) jobsHandler := handlers.NewJobsHandler(queries, worker) @@ -162,6 +168,7 @@ func main() { ConnManager: connManager, QueueProcessor: queueProcessor, ProgressService: progressService, + AnnotationService: annotationService, DeviceAuthMiddleware: deviceAuthMiddleware, JobsHandler: jobsHandler, LoginTracker: loginAttemptTracker, diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index a79906d..d7a86ab 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -84,6 +84,7 @@ type TestServerSetup struct { ConnManager *wsync.ConnectionManager QueueProcessor *wsync.SyncQueueProcessor ProgressService *wsync.ProgressService + AnnotationService *wsync.AnnotationService CleanupCancel context.CancelFunc QueueCtx context.Context QueueCancel context.CancelFunc @@ -455,6 +456,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { cleanupCancel := connManager.StartCleanupTask() progressService := wsync.NewProgressService(queries, connManager) + annotationService := wsync.NewAnnotationService(queries, connManager) queueProcessor := wsync.NewSyncQueueProcessor(queries) queueProcessor.SetProgressService(progressService) @@ -463,6 +465,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) koreaderHandler.SetProgressService(progressService) + koreaderHandler.SetAnnotationService(annotationService) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) conflictHandler := handlers.NewConflictHandler(queries, connManager) analyticsHandler := handlers.NewAnalyticsHandler(queries) @@ -482,6 +485,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { seriesHandler := handlers.NewSeriesHandler(queries) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) mediaHandler.SetProgressService(progressService) + mediaHandler.SetAnnotationService(annotationService) matchingHandler := handlers.NewMatchingHandler(queries, connManager) // Create conversion service for OPDS @@ -537,6 +541,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { ConnManager: connManager, QueueProcessor: queueProcessor, ProgressService: progressService, + AnnotationService: annotationService, DeviceAuthMiddleware: deviceAuthMiddleware, LoginTracker: loginAttemptTracker, } diff --git a/internal/handlers/conflicts.go b/internal/handlers/conflicts.go index 2d6095f..6b6023d 100644 --- a/internal/handlers/conflicts.go +++ b/internal/handlers/conflicts.go @@ -225,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error { return echo.NewHTTPError(http.StatusForbidden, "access denied") } - if conflict.ResolutionStatus.String != "unresolved" { + if conflict.ResolutionStatus.String == "user_resolved" { return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved") } @@ -258,6 +258,12 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error { } } + if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" { + if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil { + appliedTo["annotations"] = true + } + } + resolutionData := map[string]interface{}{ "winner": req.Winner, "applied_to": appliedTo, @@ -356,6 +362,143 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI return err } +func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error { + ctx := context.Background() + + dedupKey, _ := winnerData["dedup_key"].(string) + if dedupKey == "" { + return errors.New("missing dedup_key in winner data") + } + + switch conflictType { + case "annotation_highlight": + return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData) + case "annotation_bookmark": + return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData) + case "annotation_note": + return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData) + default: + return errors.New("unknown annotation conflict type") + } +} + +func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error { + existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{ + MediaItemID: mediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil { + return err + } + + params := database.UpdateMediaHighlightForSyncParams{ + ID: existing.ID, + SelectionText: existing.SelectionText, + StartPosition: existing.StartPosition, + EndPosition: existing.EndPosition, + Color: existing.Color, + NoteText: existing.NoteText, + PercentageStart: existing.PercentageStart, + PercentageEnd: existing.PercentageEnd, + EpubcfiStart: existing.EpubcfiStart, + EpubcfiEnd: existing.EpubcfiEnd, + ChapterReference: existing.ChapterReference, + LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true}, + DeviceSyncData: existing.DeviceSyncData, + } + + if v, ok := data["selection_text"].(string); ok { + params.SelectionText = v + } + if v, ok := data["color"].(string); ok { + params.Color = pgtype.Text{String: v, Valid: true} + } + if v, ok := data["note_text"].(string); ok { + params.NoteText = pgtype.Text{String: v, Valid: true} + } + if v, ok := data["start_position"].(string); ok { + params.StartPosition = pgtype.Text{String: v, Valid: true} + } + if v, ok := data["end_position"].(string); ok { + params.EndPosition = pgtype.Text{String: v, Valid: true} + } + + _, err = h.db.UpdateMediaHighlightForSync(ctx, params) + return err +} + +func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error { + existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{ + MediaItemID: mediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil { + return err + } + + params := database.UpdateMediaBookmarkForSyncParams{ + ID: existing.ID, + PageNumber: existing.PageNumber, + ChapterNumber: existing.ChapterNumber, + CfiPosition: existing.CfiPosition, + Title: existing.Title, + Position: existing.Position, + Notes: existing.Notes, + PercentageLocation: existing.PercentageLocation, + EpubcfiLocation: existing.EpubcfiLocation, + ChapterReference: existing.ChapterReference, + LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true}, + DeviceSyncData: existing.DeviceSyncData, + } + + if v, ok := data["title"].(string); ok { + params.Title = v + } + if v, ok := data["notes"].(string); ok { + params.Notes = pgtype.Text{String: v, Valid: true} + } + + _, err = h.db.UpdateMediaBookmarkForSync(ctx, params) + return err +} + +func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error { + existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{ + MediaItemID: mediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil { + return err + } + + params := database.UpdateMediaNoteForSyncParams{ + ID: existing.ID, + Content: existing.Content, + Position: existing.Position, + PercentageLocation: existing.PercentageLocation, + CharacterStart: existing.CharacterStart, + CharacterEnd: existing.CharacterEnd, + EpubcfiLocation: existing.EpubcfiLocation, + ChapterReference: existing.ChapterReference, + ParagraphReference: existing.ParagraphReference, + LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true}, + DeviceSyncData: existing.DeviceSyncData, + } + + if v, ok := data["content"].(string); ok { + params.Content = v + } + if v, ok := data["position"].(string); ok { + params.Position = pgtype.Text{String: v, Valid: true} + } + + _, err = h.db.UpdateMediaNoteForSync(ctx, params) + return err +} + func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string { devices, err := h.db.ListDevicesByType(context.Background(), "koreader") if err != nil { diff --git a/internal/handlers/kobo.go b/internal/handlers/kobo.go index 3f44442..d36efd4 100644 --- a/internal/handlers/kobo.go +++ b/internal/handlers/kobo.go @@ -3,6 +3,7 @@ package handlers import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" + "encoding/json" "fmt" "log" "net/http" @@ -19,6 +20,7 @@ type KoboHandler struct { db *database.Queries connManager *wsync.ConnectionManager progressSvc *wsync.ProgressService + annotationSvc *wsync.AnnotationService libraryService LibraryPathResolver } @@ -30,6 +32,10 @@ func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) { h.progressSvc = svc } +func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) { + h.annotationSvc = svc +} + func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) { h.libraryService = svc } @@ -245,9 +251,16 @@ type KoboInitResponse struct { } type KoboSyncStatus struct { - Status string `json:"Status"` - MarkupsSynced int `json:"MarkupsSynced"` - BookmarksSynced int `json:"BookmarksSynced"` + Status string `json:"Status"` + MarkupsSynced int `json:"MarkupsSynced"` + BookmarksSynced int `json:"BookmarksSynced"` + DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"` +} + +type KoboDeletedAnnotation struct { + ContentId string `json:"ContentId"` + BookmarkId string `json:"BookmarkId"` + Type string `json:"Type"` } type KoboServerSyncData struct { @@ -311,7 +324,7 @@ func (h *KoboHandler) Initialization(c *echo.Context) error { } bookmarkCount := 0 - annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ + annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{ MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}, UserID: pgUserID, }) @@ -403,6 +416,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error { markupsSynced := 0 bookmarksSynced := 0 unlinkedBooks := 0 + processedBooks := make(map[pgtype.UUID]string) for _, readingSync := range req.ReadingSync { bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID) @@ -412,6 +426,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error { } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} + processedBooks[pgMediaUUID] = readingSync.ContentId percentage := readingSync.PercentRead / 100.0 // Kobo only sends a percentage. For fixed-layout & comic formats the page @@ -465,29 +480,72 @@ func (h *KoboHandler) Markup(c *echo.Context) error { } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} + processedBooks[pgMediaUUID] = bookmarkSync.ContentId switch bookmarkSync.BookmarkType { case "annotation": if bookmarkSync.BookmarkText != "" { - h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - SelectionText: bookmarkSync.BookmarkText, - StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - Color: pgtype.Text{String: "#ffff00", Valid: true}, - }) - bookmarksSynced++ + if h.annotationSvc != nil { + deviceData, _ := json.Marshal(map[string]interface{}{ + "bookmark_id": bookmarkSync.BookmarkId, + "date_created": bookmarkSync.DateCreated, + }) + + result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + SelectionText: bookmarkSync.BookmarkText, + StartPosition: bookmarkSync.BookmarkId, + EndPosition: bookmarkSync.BookmarkId, + Color: "#ffff00", + NoteText: bookmarkSync.BookmarkTitle, + Source: "kobo", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + bookmarksSynced++ + } + } else { + h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + SelectionText: bookmarkSync.BookmarkText, + StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, + EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, + Color: pgtype.Text{String: "#ffff00", Valid: true}, + }) + bookmarksSynced++ + } } case "bookmark": if bookmarkSync.BookmarkText != "" { - h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Content: bookmarkSync.BookmarkText, - Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - }) - bookmarksSynced++ + if h.annotationSvc != nil { + deviceData, _ := json.Marshal(map[string]interface{}{ + "bookmark_id": bookmarkSync.BookmarkId, + "date_created": bookmarkSync.DateCreated, + }) + + result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + Title: bookmarkSync.BookmarkText, + Position: bookmarkSync.BookmarkId, + ChapterNumber: int32(bookmarkSync.Chapter), + Source: "kobo", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + bookmarksSynced++ + } + } else { + h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + Content: bookmarkSync.BookmarkText, + Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, + }) + bookmarksSynced++ + } } case "last-read-place": if bookmarkSync.BookmarkId != "" { @@ -564,6 +622,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error { BookmarksSynced: bookmarksSynced, } + if h.annotationSvc != nil && len(processedBooks) > 0 { + cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true} + for mediaItemID, contentId := range processedBooks { + tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{ + MediaItemID: mediaItemID, + UserID: pgUserID, + DeletedAt: cutoff, + }) + for _, ts := range tombstones { + var dd map[string]interface{} + if len(ts.DeviceSyncData) > 0 { + json.Unmarshal(ts.DeviceSyncData, &dd) + } + bookmarkID, _ := dd["bookmark_id"].(string) + if bookmarkID == "" { + continue + } + response.DeletedAnnotations = append(response.DeletedAnnotations, KoboDeletedAnnotation{ + ContentId: contentId, + BookmarkId: bookmarkID, + Type: ts.AnnotationType, + }) + } + } + } + // Include unlinked books count if any if unlinkedBooks > 0 { // For now, just log it. In production, this should trigger an alert @@ -606,25 +690,67 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error { switch bookmarkSync.BookmarkType { case "annotation": if bookmarkSync.BookmarkText != "" { - h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - SelectionText: bookmarkSync.BookmarkText, - StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - Color: pgtype.Text{String: "#ffff00", Valid: true}, - }) - bookmarksSynced++ + if h.annotationSvc != nil { + deviceData, _ := json.Marshal(map[string]interface{}{ + "bookmark_id": bookmarkSync.BookmarkId, + "date_created": bookmarkSync.DateCreated, + }) + + result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + SelectionText: bookmarkSync.BookmarkText, + StartPosition: bookmarkSync.BookmarkId, + EndPosition: bookmarkSync.BookmarkId, + Color: "#ffff00", + NoteText: bookmarkSync.BookmarkTitle, + Source: "kobo", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + bookmarksSynced++ + } + } else { + h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + SelectionText: bookmarkSync.BookmarkText, + StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, + EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, + Color: pgtype.Text{String: "#ffff00", Valid: true}, + }) + bookmarksSynced++ + } } case "bookmark": if bookmarkSync.BookmarkText != "" { - h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Content: bookmarkSync.BookmarkText, - Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - }) - bookmarksSynced++ + if h.annotationSvc != nil { + deviceData, _ := json.Marshal(map[string]interface{}{ + "bookmark_id": bookmarkSync.BookmarkId, + "date_created": bookmarkSync.DateCreated, + }) + + result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + Title: bookmarkSync.BookmarkText, + Position: bookmarkSync.BookmarkId, + ChapterNumber: int32(bookmarkSync.Chapter), + Source: "kobo", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + bookmarksSynced++ + } + } else { + h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + Content: bookmarkSync.BookmarkText, + Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, + }) + bookmarksSynced++ + } } } } @@ -756,36 +882,79 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error { for _, bookmark := range syncData.Bookmarks { if bookmark.BookmarkType == "bookmark" { - h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Content: bookmark.BookmarkText, - Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, - }) - bookmarksSent++ + if h.annotationSvc != nil { + result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + Title: bookmark.BookmarkText, + Position: bookmark.BookmarkId, + Source: "kobo", + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + bookmarksSent++ + } + } else { + h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + Content: bookmark.BookmarkText, + Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, + }) + bookmarksSent++ + } } else if bookmark.BookmarkType == "annotation" { - h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - SelectionText: bookmark.BookmarkText, - StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, - EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, - Color: pgtype.Text{String: "#ffff00", Valid: true}, - }) - highlightsSent++ + if h.annotationSvc != nil { + result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + SelectionText: bookmark.BookmarkText, + StartPosition: bookmark.BookmarkId, + EndPosition: bookmark.BookmarkId, + Color: "#ffff00", + Source: "kobo", + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + highlightsSent++ + } + } else { + h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + SelectionText: bookmark.BookmarkText, + StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, + EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, + Color: pgtype.Text{String: "#ffff00", Valid: true}, + }) + highlightsSent++ + } } } for _, highlight := range syncData.Highlights { - h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - SelectionText: highlight.BookmarkText, - StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true}, - EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true}, - Color: pgtype.Text{String: "#ffff00", Valid: true}, - }) - highlightsSent++ + if h.annotationSvc != nil { + result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + SelectionText: highlight.BookmarkText, + StartPosition: highlight.BookmarkId, + EndPosition: highlight.BookmarkId, + Color: "#ffff00", + Source: "kobo", + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + highlightsSent++ + } + } else { + h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + SelectionText: highlight.BookmarkText, + StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true}, + EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true}, + Color: pgtype.Text{String: "#ffff00", Valid: true}, + }) + highlightsSent++ + } } } diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index 895ebea..a28b1d0 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -4,6 +4,7 @@ import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" "context" + "encoding/json" "fmt" "log" "net/http" @@ -19,6 +20,7 @@ type KOReaderHandler struct { connManager *wsync.ConnectionManager queue *wsync.SyncQueueProcessor progressSvc *wsync.ProgressService + annotationSvc *wsync.AnnotationService libraryService LibraryPathResolver } @@ -34,6 +36,27 @@ func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) { h.progressSvc = svc } +func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) { + h.annotationSvc = svc +} + +func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1 string) (string, string) { + if pos0 == "" || h.libraryService == nil { + return "", "" + } + mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID) + if err != nil { + return "", "" + } + epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath) + if err != nil || epubPath == "" { + return "", "" + } + startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, "", mediaItem.FormatGroup, epubPath, "") + endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "") + return startLoc.CFI, endLoc.CFI +} + func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) { h.libraryService = svc } @@ -154,9 +177,11 @@ type KOReaderProgressData struct { } type KOReaderAnnotations struct { - Highlights []KOReaderHighlight `json:"highlights,omitempty"` - Notes []KOReaderNote `json:"notes,omitempty"` - Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` + Highlights []KOReaderHighlight `json:"highlights,omitempty"` + Notes []KOReaderNote `json:"notes,omitempty"` + Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` + DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"` + DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"` } type KOReaderLibraryResponse struct { @@ -396,6 +421,7 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database. if synced { booksEnqueued++ } + h.processBookAnnotations(c.Request().Context(), device.ID, userID, mediaItemID, book) bookResults = append(bookResults, KOReaderBookSyncResult{ SHA256: book.SHA256, BookUUID: uuid.UUID(mediaItemID.Bytes).String(), @@ -441,6 +467,102 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp return h.queue.EnqueueProgress(update) } +func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, userID, mediaItemID pgtype.UUID, book KOReaderBookProgress) { + if h.annotationSvc == nil { + return + } + + for _, hl := range book.Highlights { + startPos := hl.Pos0 + endPos := hl.Pos1 + epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos) + + pctStart := 0.0 + if hl.Percentage != nil { + pctStart = *hl.Percentage + } + + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": hl.Datetime, + "pos0": hl.Pos0, + "pos1": hl.Pos1, + "page": hl.Page, + }) + + h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ + MediaItemID: mediaItemID, + UserID: userID, + SelectionText: hl.Text, + StartPosition: startPos, + EndPosition: endPos, + Color: hl.Color, + NoteText: hl.Notes, + PercentageStart: pctStart, + EpubcfiStart: epubcfiStart, + EpubcfiEnd: epubcfiEnd, + Source: "koreader", + DeviceSyncData: deviceData, + }) + } + + for _, note := range book.Notes { + startPos := note.Pos0 + endPos := note.Pos1 + epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos) + + pctStart := 0.0 + if note.Percentage != nil { + pctStart = *note.Percentage + } + + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": note.Datetime, + "pos0": note.Pos0, + "pos1": note.Pos1, + "page": note.Page, + }) + + h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ + MediaItemID: mediaItemID, + UserID: userID, + SelectionText: note.Text, + StartPosition: startPos, + EndPosition: endPos, + NoteText: note.Notes, + PercentageStart: pctStart, + EpubcfiStart: epubcfiStart, + EpubcfiEnd: epubcfiEnd, + Source: "koreader", + DeviceSyncData: deviceData, + }) + } + + for _, bookmark := range book.Bookmarks { + position := "" + if bookmark.Pos0 != "" { + position = bookmark.Pos0 + } else if bookmark.Page > 0 { + position = fmt.Sprintf("page:%d", bookmark.Page) + } + + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": bookmark.Datetime, + "pos0": bookmark.Pos0, + "page": bookmark.Page, + }) + + h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{ + MediaItemID: mediaItemID, + UserID: userID, + Title: bookmark.Text, + Position: position, + ChapterNumber: int32(bookmark.Chapter), + Source: "koreader", + DeviceSyncData: deviceData, + }) + } +} + func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error { ctx := c.Request().Context() @@ -522,7 +644,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype } _, err := h.progressSvc.SaveProgress(ctx, saveReq) - return err + if err != nil { + return err + } + h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book) + return nil } _, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{ @@ -558,6 +684,8 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype }, ) + h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book) + return nil } @@ -657,7 +785,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { progressData.TotalPages = &progress } - annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ + annotations, err := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{ MediaItemID: pgBookUUID, UserID: pgUserID, }) @@ -670,13 +798,29 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { for _, ann := range annotations { if ann.AnnotationType == "highlight" { - annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{ + pos0 := ann.StartPosition.String + pos1 := ann.EndPosition.String + if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" { + if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" { + pos0 = converted + } + } + if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" { + if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" { + pos1 = converted + } + } + highlight := KOReaderHighlight{ Text: ann.SelectionText, - Pos0: ann.StartPosition.String, - Pos1: ann.EndPosition.String, + Pos0: pos0, + Pos1: pos1, Color: ann.Color.String, Datetime: ann.CreatedAt.Time.Format(time.RFC3339), - }) + } + if ann.NoteText.Valid && ann.NoteText.String != "" { + highlight.Notes = ann.NoteText.String + } + annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight) } else if ann.AnnotationType == "note" { annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{ Text: ann.SelectionText, @@ -686,6 +830,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { } } + bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{ + MediaItemID: pgBookUUID, + UserID: pgUserID, + }) + for _, bm := range bookmarks { + pos0 := bm.Position.String + if pos0 == "" && bm.CfiPosition.Valid { + pos0 = bm.CfiPosition.String + } + koreaderBookmark := KOReaderBookmark{ + Text: bm.Title, + Pos0: pos0, + Pos1: pos0, + Datetime: bm.CreatedAt.Time.Format(time.RFC3339), + } + if bm.Notes.Valid && bm.Notes.String != "" { + koreaderBookmark.Notes = bm.Notes.String + } + if bm.ChapterNumber.Valid { + koreaderBookmark.Chapter = int(bm.ChapterNumber.Int32) + } + annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark) + } + + cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true} + tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{ + MediaItemID: pgBookUUID, + UserID: pgUserID, + DeletedAt: cutoff, + }) + for _, ts := range tombstones { + var dd map[string]interface{} + if len(ts.DeviceSyncData) > 0 { + json.Unmarshal(ts.DeviceSyncData, &dd) + } + if dd == nil { + dd = map[string]interface{}{} + } + dd["dedup_key"] = ts.DedupKey.String + if ts.AnnotationType == "highlight" { + annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd) + } else if ts.AnnotationType == "bookmark" { + annotationsResponse.DeletedBookmarks = append(annotationsResponse.DeletedBookmarks, dd) + } + } + lastSync := "never" if progress.LastSyncTimestamp.Valid { lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339) @@ -737,6 +927,21 @@ func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem databa } } +func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string) string { + if h.libraryService == nil || epubcfi == "" { + return "" + } + epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) + if err != nil || epubPath == "" { + return "" + } + loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, "", mediaItem.FormatGroup, epubPath, "") + if loc.Position != "" && loc.Position != epubcfi { + return loc.Position + } + return "" +} + func (h *KOReaderHandler) GetLibrary(c *echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes @@ -776,7 +981,7 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error { } } - annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ + annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{ MediaItemID: pgItemUUID, UserID: pgUserID, }) @@ -876,15 +1081,36 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { position = fmt.Sprintf("page:%d", bookmark.Page) } - _, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{ - MediaItemID: mediaItemID, - UserID: pgUserID, - Content: bookmark.Text, - Position: pgtype.Text{String: position, Valid: position != ""}, - }) + if h.annotationSvc != nil { + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": bookmark.Datetime, + "pos0": bookmark.Pos0, + "page": bookmark.Page, + }) - if err == nil { - bookmarksSynced++ + result, err := h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{ + MediaItemID: mediaItemID, + UserID: pgUserID, + Title: bookmark.Text, + Position: position, + ChapterNumber: int32(bookmark.Chapter), + Source: "koreader", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + bookmarksSynced++ + } + } else { + _, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{ + MediaItemID: mediaItemID, + UserID: pgUserID, + Content: bookmark.Text, + Position: pgtype.Text{String: position, Valid: position != ""}, + }) + + if err == nil { + bookmarksSynced++ + } } } @@ -906,15 +1132,35 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { position = fmt.Sprintf("page:%d", note.Page) } - _, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{ - MediaItemID: mediaItemID, - UserID: pgUserID, - Content: note.Notes, - Position: pgtype.Text{String: position, Valid: position != ""}, - }) + if h.annotationSvc != nil { + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": note.Datetime, + "pos0": note.Pos0, + "page": note.Page, + }) - if err == nil { - notesSynced++ + result, err := h.annotationSvc.SaveNote(ctx, wsync.SaveNoteRequest{ + MediaItemID: mediaItemID, + UserID: pgUserID, + Content: note.Notes, + Position: position, + Source: "koreader", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + notesSynced++ + } + } else { + _, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{ + MediaItemID: mediaItemID, + UserID: pgUserID, + Content: note.Notes, + Position: pgtype.Text{String: position, Valid: position != ""}, + }) + + if err == nil { + notesSynced++ + } } } @@ -941,17 +1187,51 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { color = highlight.Color } - _, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{ - MediaItemID: mediaItemID, - UserID: pgUserID, - SelectionText: highlight.Text, - StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""}, - EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""}, - Color: pgtype.Text{String: color, Valid: true}, - }) + if h.annotationSvc != nil { + epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1) - if err == nil { - highlightsSynced++ + pctStart := 0.0 + if highlight.Percentage != nil { + pctStart = *highlight.Percentage + } + + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": highlight.Datetime, + "pos0": highlight.Pos0, + "pos1": highlight.Pos1, + "page": highlight.Page, + }) + + result, err := h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ + MediaItemID: mediaItemID, + UserID: pgUserID, + SelectionText: highlight.Text, + StartPosition: startPos, + EndPosition: endPos, + Color: color, + NoteText: highlight.Notes, + PercentageStart: pctStart, + EpubcfiStart: epubcfiStart, + EpubcfiEnd: epubcfiEnd, + Source: "koreader", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + highlightsSynced++ + } + } else { + _, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{ + MediaItemID: mediaItemID, + UserID: pgUserID, + SelectionText: highlight.Text, + StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""}, + EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""}, + Color: pgtype.Text{String: color, Valid: true}, + }) + + if err == nil { + highlightsSynced++ + } } } diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 7f4b007..9688873 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -19,6 +19,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -136,6 +137,7 @@ type MediaHandler struct { libraryService *services.LibraryService searchService *services.SearchService progressSvc *wsync.ProgressService + annotationSvc *wsync.AnnotationService } func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler { @@ -154,6 +156,10 @@ func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) { mh.progressSvc = svc } +func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) { + mh.annotationSvc = svc +} + func (h *MediaHandler) DownloadBook(c *echo.Context) error { bookUUID, err := uuid.Parse(c.Param("uuid")) if err != nil { @@ -1388,14 +1394,31 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ - MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, - UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, - Content: req.Content, - Position: pgtype.Text{String: req.Position, Valid: req.Position != ""}, - }) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + var note database.MediaNotes + if mh.annotationSvc != nil { + result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{ + MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + Content: req.Content, + Position: req.Position, + Source: "web", + ModifiedAt: time.Now(), + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + note = result.Note + } else { + var err error + note, err = mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ + MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + Content: req.Content, + Position: pgtype.Text{String: req.Position, Valid: req.Position != ""}, + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } } return c.JSON(http.StatusCreated, note) @@ -1456,7 +1479,11 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) } - err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}) + if mh.annotationSvc != nil { + err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}) + } else { + err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}) + } if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } @@ -1525,9 +1552,29 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error { color = req.Color } + pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true} + pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true} + + if mh.annotationSvc != nil { + result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ + MediaItemID: pgMediaID, + UserID: pgUserID, + SelectionText: req.SelectionText, + StartPosition: req.StartPosition, + EndPosition: req.EndPosition, + Color: color, + Source: "web", + ModifiedAt: time.Now(), + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusCreated, result.Highlight) + } + highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ - MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, - UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + MediaItemID: pgMediaID, + UserID: pgUserID, SelectionText: req.SelectionText, StartPosition: pgtype.Text{String: req.StartPosition, Valid: true}, EndPosition: pgtype.Text{String: req.EndPosition, Valid: true}, @@ -1613,7 +1660,16 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"}) } - err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true}) + pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true} + + if mh.annotationSvc != nil { + if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.NoContent(http.StatusNoContent) + } + + err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } diff --git a/internal/router/router.go b/internal/router/router.go index 8f25e62..9e2f20d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -62,6 +62,7 @@ type Config struct { ConnManager *sync.ConnectionManager QueueProcessor *sync.SyncQueueProcessor ProgressService *sync.ProgressService + AnnotationService *sync.AnnotationService DeviceAuthMiddleware *middleware.DeviceAuthMiddleware LoginTracker *ratelimit.LoginAttemptTracker ScannerHandler *handlers.Handler diff --git a/internal/router/sync.go b/internal/router/sync.go index 4731fa6..406e453 100644 --- a/internal/router/sync.go +++ b/internal/router/sync.go @@ -33,6 +33,7 @@ func registerSyncRoutes(cfg *Config) { // API clients can use Authorization header: Authorization: Bearer {token} koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager) koboHandler.SetProgressService(cfg.ProgressService) + koboHandler.SetAnnotationService(cfg.AnnotationService) koboHandler.SetLibraryService(cfg.LibraryService) koboSync := e.Group("/api/sync/kobo/:token") koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup)) From 76c6826920faa6a70e42d8cf3ee009d007b91e61 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 15:47:40 -0400 Subject: [PATCH 07/52] feat(deploy): split compose into prod base + dev override Restructure the container setup to support registry-based deployment: the default docker-compose.yml now pulls a prebuilt app image from the Gitea container registry instead of building locally, while a new docker-compose.dev.yml override preserves the local build + integration test workflow for development. Why: - Production and self-hosting should consume a published image, not rebuild from source on the host. The default `docker compose up` now pulls the app image (git.linuxhg.com/bookhoard/bookhoard) alongside the public postgres image, with no build step required. - Development still needs to build from source and run integration tests, so those concerns move to an override file the Makefile applies. Shared config (env, volumes, ports, healthchecks) lives in one place to avoid drift between environments. Changes: - docker-compose.yml (prod base): the app service now references `image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}` instead of a build context. The tests service is removed (moved to the override). IMAGE_TAG lets deployers pin or roll back a specific version. - docker-compose.dev.yml (new override): adds the local `build:` context for the app and defines the integration `tests` service (profile-gated). Everything else is inherited from the base file via compose merging. - Makefile: introduce a COMPOSE variable that merges the base and override (`-f docker-compose.yml -f docker-compose.dev.yml`); all dev targets now use it. Plain `docker compose` against the base file only remains the production path. - README: quickstart updated to pull and start prebuilt images; clone URL points at the Gitea instance. The development workflow (`make rebuild-app`, `make test-integration`, etc.) is functionally unchanged. --- Makefile | 44 +++++++++++++++++--------------- README.md | 8 +++--- docker-compose.dev.yml | 57 ++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 49 +++--------------------------------- 4 files changed, 89 insertions(+), 69 deletions(-) create mode 100644 docker-compose.dev.yml diff --git a/Makefile b/Makefile index 54e6a0b..cea32de 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,10 @@ endif # Override with: CONTAINER_RUNTIME=podman make rebuild-app CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null) +# Dev compose stack: base prod file merged with the dev override (local build + tests). +# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only. +COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml + # Default target help: @echo "Available targets:" @@ -48,9 +52,9 @@ test: # Run integration tests in containers (matches production environment) test-integration: @echo "Building test containers..." - $(CONTAINER_RUNTIME) compose --profile tests build + $(COMPOSE) --profile tests build @echo "Starting application containers..." - $(CONTAINER_RUNTIME) compose up -d db app + $(COMPOSE) up -d db app @echo "Waiting for services to be healthy..." @until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \ echo " Database not ready yet..."; \ @@ -64,7 +68,7 @@ test-integration: echo " ✓ Application is ready" @echo "" @echo "Running integration tests in container..." - $(CONTAINER_RUNTIME) compose --profile tests run --rm tests + $(COMPOSE) --profile tests run --rm tests @echo "" @echo "✅ Integration tests completed!" @echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop." @@ -75,67 +79,67 @@ test-all: test test-integration # Rebuild app container only (preserve DB, with cache) rebuild-app: @echo "Rebuilding app container (database stays running)..." - $(CONTAINER_RUNTIME) compose up --build --force-recreate -d app + $(COMPOSE) up --build --force-recreate -d app @echo "✓ App container rebuilt and restarted" # Rebuild app container only (preserve DB, no cache) rebuild-app-force: @echo "Force rebuilding app container (database stays running, no cache)..." - $(CONTAINER_RUNTIME) compose build --no-cache app - $(CONTAINER_RUNTIME) compose up --force-recreate -d app + $(COMPOSE) build --no-cache app + $(COMPOSE) up --force-recreate -d app @echo "✓ App container rebuilt and restarted" # Rebuild all containers (preserve DB, with cache) rebuild: @echo "Rebuilding all containers (database preserved)..." - $(CONTAINER_RUNTIME) compose up --build --force-recreate -d + $(COMPOSE) up --build --force-recreate -d @echo "✓ All containers rebuilt and restarted" # Rebuild all containers (preserve DB, no cache) rebuild-force: @echo "Force rebuilding all containers (database preserved, no cache)..." - $(CONTAINER_RUNTIME) compose build --no-cache - $(CONTAINER_RUNTIME) compose up --force-recreate -d + $(COMPOSE) build --no-cache + $(COMPOSE) up --force-recreate -d @echo "✓ All containers rebuilt and restarted" # Rebuild all containers (remove DB, no cache) rebuild-force-db: @echo "Force rebuilding all containers (database will be DELETED, no cache)..." - $(CONTAINER_RUNTIME) compose down -v - $(CONTAINER_RUNTIME) compose build --no-cache - $(CONTAINER_RUNTIME) compose up --force-recreate -d + $(COMPOSE) down -v + $(COMPOSE) build --no-cache + $(COMPOSE) up --force-recreate -d @echo "✓ All containers rebuilt and restarted" # Stop and remove containers clean: - $(CONTAINER_RUNTIME) compose down -v + $(COMPOSE) down -v # Quick start (if already built) up: - $(CONTAINER_RUNTIME) compose up -d + $(COMPOSE) up -d # Stop all containers (alias for clean) down: - $(CONTAINER_RUNTIME) compose down + $(COMPOSE) down # Restart app container (preserves database) restart: @echo "Restarting app container (database stays running)..." - $(CONTAINER_RUNTIME) compose restart app + $(COMPOSE) restart app @echo "✓ App container restarted" # Show container status ps: - $(CONTAINER_RUNTIME) compose ps + $(COMPOSE) ps # Show container logs logs: - $(CONTAINER_RUNTIME) compose logs -f + $(COMPOSE) logs -f # Start containers with test mode enabled for manual testing test-env-up: @echo "Starting containers with test mode enabled..." - TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(CONTAINER_RUNTIME) compose up --build --force-recreate -d + TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(COMPOSE) up --build --force-recreate -d @echo "Waiting for services to be ready..." @until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done @until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done @@ -144,7 +148,7 @@ test-env-up: # Stop test environment test-env-down: - $(CONTAINER_RUNTIME) compose down -v + $(COMPOSE) down -v # Verify project guidelines compliance verify-guidelines: diff --git a/README.md b/README.md index 0cf835f..08caec9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T ```bash # 1. Clone the repository -git clone https://github.com/yourusername/bookhoard.git +git clone https://git.linuxhg.com/Bookhoard/bookhoard.git cd bookhoard # 2. Set up environment @@ -35,8 +35,10 @@ cp .env.example .env # DBPASS: openssl rand -hex 16 # Edit .env with your generated values -# 3. Start the server -podman-compose up --build -d # or: docker-compose up --build -d +# 3. Pull images and start the server +docker compose pull +docker compose up -d +# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest") # 4. Open your browser open http://localhost:8765 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..8f43223 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,57 @@ +# Development override — merged on top of docker-compose.yml (the base/prod file). +# Activated by all `make` targets via: +# COMPOSE = compose -f docker-compose.yml -f docker-compose.dev.yml +# +# What this adds over prod: +# - Local image BUILDING (prod pulls a prebuilt image from the registry) +# - The integration-tests service (dev only, gated behind the "tests" profile) +# Everything else (env vars, volumes, ports, healthchecks) is inherited from the base file. +services: + # Build the app image locally instead of pulling from the registry + app: + build: + context: . + dockerfile: ./Dockerfile + + # Integration Tests - runs against containerized app and db (dev only) + tests: + build: + context: . + dockerfile: ./Dockerfile + target: test-runner + container_name: bookhoard_tests + environment: + # Database Configuration + DATABASE_HOST: db + DATABASE_PORT: "5432" + DATABASE_USER: postgres + DATABASE_PASSWORD: ${DBPASS} + DATABASE_NAME: bookhoard + COOKIE_SECURE: false + + # Application Configuration + JWT_SECRET: ${JWT_SECRET} + SERVER_PORT: "8765" + + # Test Configuration + TEST_MODE: "true" + RATE_LIMIT_ENABLED: "false" + REQUESTS_PER_MINUTE: 1000 + + # Conversion Service Configuration + BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub + BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify + BOOKHOARD_CONVERSION_CACHE_TTL: 24h + + # Test upload path (inside container) + TEST_UPLOAD_PATH: /app/uploads + depends_on: + db: + condition: service_healthy + app: + condition: service_healthy + volumes: + - ./uploads:/app/uploads + - bookhoard_conversion_cache:/app/cache/kepub + profiles: + - tests diff --git a/docker-compose.yml b/docker-compose.yml index c652ef1..c43d3e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,10 +27,10 @@ services: - .env # Bookhoard Application + # In production this image is pulled from the Gitea container registry. + # Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest"). app: - build: - context: . - dockerfile: ./Dockerfile + image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest} container_name: bookhoard environment: # Database Configuration @@ -76,49 +76,6 @@ services: retries: 3 start_period: 10s - # Integration Tests - runs against containerized app and db - tests: - build: - context: . - dockerfile: ./Dockerfile - target: test-runner - container_name: bookhoard_tests - environment: - # Database Configuration - DATABASE_HOST: db - DATABASE_PORT: "5432" - DATABASE_USER: postgres - DATABASE_PASSWORD: ${DBPASS} - DATABASE_NAME: bookhoard - COOKIE_SECURE: false - - # Application Configuration - JWT_SECRET: ${JWT_SECRET} - SERVER_PORT: "8765" - - # Test Configuration - TEST_MODE: "true" - RATE_LIMIT_ENABLED: "false" - REQUESTS_PER_MINUTE: 1000 - - # Conversion Service Configuration - BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub - BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify - BOOKHOARD_CONVERSION_CACHE_TTL: 24h - - # Test upload path (inside container) - TEST_UPLOAD_PATH: /app/uploads - depends_on: - db: - condition: service_healthy - app: - condition: service_healthy - volumes: - - ./uploads:/app/uploads - - bookhoard_conversion_cache:/app/cache/kepub - profiles: - - tests - # Named Volumes volumes: postgres_data: From 1129fcae6f3122475ab193ab5cefc7ce4a757de5 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 16:02:32 -0400 Subject: [PATCH 08/52] fix(compose): make BASE_URL/COOKIE_SECURE configurable, drop obsolete version Address compose issues surfaced on first production deploy: - Remove obsolete `version: "3.8"` (ignored by Compose v2; caused a warning). - Fix BASE_URL: it used compose-time interpolation of ${SERVER_PORT}, which is only defined as a runtime container env var (invisible to interpolation) and absent from .env. This resolved to an empty string, producing a broken `http://localhost:` (no port) and a startup warning. Now ${BASE_URL:-http://localhost:8765}, overridable per-deployment via .env. - Move COOKIE_SECURE from the db service to the app service and make it configurable (${COOKIE_SECURE:-false}). It controls the session cookie Secure flag, an app concern; on the db service it was a no-op, so the app never received it and cookies were always non-secure. Set COOKIE_SECURE=true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik), where the app speaks plain HTTP internally. - Image reference unchanged: ${IMAGE_TAG:-latest} (no hardcoded version). --- docker-compose.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c43d3e5..1e794e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: # PostgreSQL Database db: @@ -9,7 +7,6 @@ services: POSTGRES_DB: bookhoard POSTGRES_USER: postgres POSTGRES_PASSWORD: ${DBPASS} - COOKIE_SECURE: false # make true in production with HTTPS volumes: - postgres_data:/var/lib/postgresql/data - ./database/schema:/docker-entrypoint-initdb.d @@ -47,7 +44,9 @@ services: # Local: http://localhost:8765 # Local network: http://192.168.1.X:8765 # Domain: https://bookhoard.example.com - BASE_URL: http://localhost:${SERVER_PORT} + BASE_URL: ${BASE_URL:-http://localhost:8765} + # Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik) + COOKIE_SECURE: ${COOKIE_SECURE:-false} # Rate Limiting Configuration TEST_MODE: ${TEST_MODE:-false} From de8f71b2be275692bda4145bc4426295e68a0467 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 16:11:01 -0400 Subject: [PATCH 09/52] refactor(compose): make app/db ports configurable via DB_PORT and SERVER_PORT Replace hardcoded port literals with env-driven variables so a single change in .env reconfigures the full stack consistently. Defaults are unchanged (DB 5432, app 8765), so existing setups need no .env changes. - DB_PORT (default 5432): drives the db host<->container port mapping, Postgres PGPORT (so it listens on the chosen port), and the app's DATABASE_PORT connection setting. Lets deployers avoid a host port conflict (e.g. another local Postgres) by setting DB_PORT once. - SERVER_PORT (default 8765): drives the app host<->container mapping, the SERVER_PORT the app listens on, and the healthcheck target URL. - Applied to both the base (docker-compose.yml) and the dev override (docker-compose.dev.yml, tests service) so dev and prod stay in sync. --- docker-compose.dev.yml | 4 ++-- docker-compose.yml | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8f43223..9d69c8a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -23,7 +23,7 @@ services: environment: # Database Configuration DATABASE_HOST: db - DATABASE_PORT: "5432" + DATABASE_PORT: ${DB_PORT:-5432} DATABASE_USER: postgres DATABASE_PASSWORD: ${DBPASS} DATABASE_NAME: bookhoard @@ -31,7 +31,7 @@ services: # Application Configuration JWT_SECRET: ${JWT_SECRET} - SERVER_PORT: "8765" + SERVER_PORT: ${SERVER_PORT:-8765} # Test Configuration TEST_MODE: "true" diff --git a/docker-compose.yml b/docker-compose.yml index 1e794e4..099eb17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,15 @@ services: POSTGRES_DB: bookhoard POSTGRES_USER: postgres POSTGRES_PASSWORD: ${DBPASS} + # PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT) + PGPORT: ${DB_PORT:-5432} volumes: - postgres_data:/var/lib/postgresql/data - ./database/schema:/docker-entrypoint-initdb.d # Make other volumes as needed - ./uploads:/app/uploads ports: - - "5432:5432" + - "${DB_PORT:-5432}:${DB_PORT:-5432}" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 30s @@ -32,14 +34,14 @@ services: environment: # Database Configuration DATABASE_HOST: db - DATABASE_PORT: "5432" + DATABASE_PORT: ${DB_PORT:-5432} DATABASE_USER: postgres DATABASE_PASSWORD: ${DBPASS} DATABASE_NAME: bookhoard # Application Configuration JWT_SECRET: ${JWT_SECRET} - SERVER_PORT: "8765" + SERVER_PORT: ${SERVER_PORT:-8765} # IMPORTANT: Device sync requires full URL with protocol # Local: http://localhost:8765 # Local network: http://192.168.1.X:8765 @@ -61,7 +63,7 @@ services: # System timezone (fallback for server-side time operations) TZ: ${TZ:-UTC} ports: - - "8765:8765" + - "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}" depends_on: db: condition: service_healthy @@ -69,7 +71,7 @@ services: - ./uploads:/app/uploads - bookhoard_conversion_cache:/app/cache/kepub healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"] + test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"] interval: 30s timeout: 5s retries: 3 From bac84e24eca432eb0e07f9aa9f982c1f57e03c78 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 16:12:21 -0400 Subject: [PATCH 10/52] docs(env): document DB_PORT, SERVER_PORT, BASE_URL, COOKIE_SECURE, IMAGE_TAG in .env.example Expose the recently-added env-driven compose settings as commented examples so self-hosters and deployers can discover them. All remain optional with defaults. --- .env.example | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.env.example b/.env.example index cdb581b..d4721b2 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,21 @@ JWT_SECRET=your-secure-jwt-secret-key-here # Generate with: openssl rand -hex 16 DBPASS=your-secure-database-password-here +# Networking: change a port if it conflicts on your host +# Postgres port, host + container (e.g. another local DB already uses 5432) +# DB_PORT=15432 +# App web port, host + container +# SERVER_PORT=8765 + +# Deployment +# External URL for device sync (must include protocol; defaults to http://localhost:8765) +# Examples: https://bookhoard.example.com | http://192.168.1.10:8765 +# BASE_URL=https://bookhoard.example.com +# Mark session cookies Secure — set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik) +# COOKIE_SECURE=true +# Pin or rollback a specific published image version (defaults to "latest") +# IMAGE_TAG=1.0.0 + # Optional: Override Defaults (defaults are set in docker-compose.yml) # Test Mode: WARNING - Only set to true for integration testing # TEST_MODE=true From e7c4c931ee1fbcd3acb6be853b378f2cca0cab6f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 16:53:43 -0400 Subject: [PATCH 11/52] ci(release): add tag-triggered image build & push to Gitea registry Adds .gitea/workflows/release.yml. On a v* git tag push (or manual dispatch), builds the Dockerfile and publishes to git.linuxhg.com/bookhoard/bookhoard under two tags: the version (${{ gitea.ref_name }}) and 'latest'. Auth uses the auto-provided GITHUB_TOKEN; no secret to manage. Pushes to main do nothing, so work-in-progress commits never ship. --- .gitea/workflows/release.yml | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .gitea/workflows/release.yml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..de30899 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +# Builds and publishes the Bookhoard container image to the Gitea container registry. +# Triggered ONLY by a version tag push (pushing to main does nothing), so work-in-progress +# commits never ship. Each release publishes two image tags: the version and "latest". +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: git.linuxhg.com + username: ${{ gitea.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + # Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag. + # Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml; + # pin or roll back by setting IMAGE_TAG in .env. + tags: | + git.linuxhg.com/bookhoard/bookhoard:${{ gitea.ref_name }} + git.linuxhg.com/bookhoard/bookhoard:latest From 3c9e4d8126d341b8a261f6c7f481f066324382e5 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 17:07:00 -0400 Subject: [PATCH 12/52] fix(ci): use REGISTRY_TOKEN PAT secret for registry login Gitea's auto GITHUB_TOKEN lacks the package scope needed to push to the container registry, causing the login step to fail. Switch the login password to a PAT stored as the REGISTRY_TOKEN repo Actions secret (scopes: write:package, read:package). --- .gitea/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index de30899..80c784f 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -27,7 +27,8 @@ jobs: with: registry: git.linuxhg.com username: ${{ gitea.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + # PAT stored as a repo Actions secret (auto GITHUB_TOKEN lacks package scope in Gitea) + password: ${{ secrets.REGISTRY_TOKEN }} - name: Build and push image uses: docker/build-push-action@v5 From 114a4574b0b7cce568bb0ccef248a4c68c2432a3 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 11:41:05 -0400 Subject: [PATCH 13/52] feat(db): add query to count media items per visible library Add GetVisibleLibraryMediaCounts, which returns the media item count for each library visible to a given user in a single GROUP BY query over media_items. It mirrors the visibility logic in GetUserVisibleLibraries (libraries default to visible unless an explicit false row exists) so counts can be resolved in one round-trip instead of N per-library lookups. Regenerated sqlc bindings (querier.go, queries.sql.go). --- internal/database/querier.go | 1 + internal/database/queries.sql.go | 34 +++++++++++++++++++++++++++ internal/database/queries/queries.sql | 8 +++++++ 3 files changed, 43 insertions(+) diff --git a/internal/database/querier.go b/internal/database/querier.go index 4a6f720..5d8fbc2 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -303,6 +303,7 @@ type Querier interface { // Get user reading history for analytics GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) + GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error) // Check if book is in collection diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 4d8327c..91961c5 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -7493,6 +7493,40 @@ func (q *Queries) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUI return items, nil } +const GetVisibleLibraryMediaCounts = `-- name: GetVisibleLibraryMediaCounts :many +SELECT l.id, COUNT(mi.id) as media_count +FROM libraries l +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +LEFT JOIN media_items mi ON mi.library_id = l.id +WHERE COALESCE(lv.is_visible, true) = true +GROUP BY l.id +` + +type GetVisibleLibraryMediaCountsRow struct { + ID pgtype.UUID `db:"id" json:"id"` + MediaCount int64 `db:"media_count" json:"media_count"` +} + +func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) { + rows, err := q.db.Query(ctx, GetVisibleLibraryMediaCounts, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetVisibleLibraryMediaCountsRow{} + for rows.Next() { + var i GetVisibleLibraryMediaCountsRow + if err := rows.Scan(&i.ID, &i.MediaCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one SELECT EXISTS( SELECT 1 FROM sync_conflicts diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index b26d576..eb1220a 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -137,6 +137,14 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 WHERE COALESCE(lv.is_visible, true) = true ORDER BY l.created_at ASC; +-- name: GetVisibleLibraryMediaCounts :many +SELECT l.id, COUNT(mi.id) as media_count +FROM libraries l +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 +LEFT JOIN media_items mi ON mi.library_id = l.id +WHERE COALESCE(lv.is_visible, true) = true +GROUP BY l.id; + -- Media Items queries -- name: CreateMediaItem :one INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name) From 05370d236ada0e3395f8b8685139dd64673d4784 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 11:41:13 -0400 Subject: [PATCH 14/52] feat(ui): display media counts in library switcher The UI had no surface showing how many media items have been imported. Surface the total in the library switcher shown on the Dashboard, Series, and Collections pages (via the LibrarySwitcher component) and in the Bookshelf's inline library filter. - Add a MediaCount field to LibraryData and a TotalMediaCount helper to sum counts for the "All Libraries" / "All Books" option. - resolveLibrary() now fetches per-library counts (one query) and maps them onto each LibraryData entry, so the switcher reflects the active scope without changing the component's signature. - Each library option renders "(N)" and the "All" option renders the grand total across the user's visible libraries. The "All" total is the sum of the user's visible libraries, correctly respecting per-user library visibility rather than a raw global count. Regenerated templ files for library_switcher and bookshelf. --- internal/router/helpers.go | 12 ++ templates/bookshelf.templ | 24 ++-- templates/bookshelf_templ.go | 188 ++++++++++++++++++---------- templates/library_switcher.templ | 14 +-- templates/library_switcher_templ.go | 117 +++++++++++------ templates/types.go | 1 + templates/utils.go | 10 ++ 7 files changed, 240 insertions(+), 126 deletions(-) diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 2d3ff3a..88441bf 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -118,6 +118,17 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu libraries = []database.GetUserVisibleLibrariesRow{} } + counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU)) + if countErr != nil { + log.Printf("GetVisibleLibraryMediaCounts failed: %v", countErr) + counts = []database.GetVisibleLibraryMediaCountsRow{} + } + countMap := make(map[string]int64, len(counts)) + for _, mc := range counts { + mcUUID, _ := uuid.FromBytes(mc.ID.Bytes[0:16]) + countMap[mcUUID.String()] = mc.MediaCount + } + res.Libraries = make([]templates.LibraryData, len(libraries)) for i, lib := range libraries { libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) @@ -126,6 +137,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu Name: lib.Name, Description: getText(lib.Description), TypeName: lib.TypeName, + MediaCount: countMap[libUUID.String()], } } diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ index b0166b4..3ef859c 100644 --- a/templates/bookshelf.templ +++ b/templates/bookshelf.templ @@ -50,22 +50,22 @@ templ BookShelf( class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" > - if len(libraries) == 0 { - + if len(libraries) == 0 { + + } else { + if currentLibraryID == "" { + } else { - if currentLibraryID == "" { - + + } + for _, lib := range libraries { + if lib.ID == currentLibraryID { + } else { - - } - for _, lib := range libraries { - if lib.ID == currentLibraryID { - - } else { - - } + } } + } diff --git a/templates/bookshelf_templ.go b/templates/bookshelf_templ.go index 148732b..41af741 100644 --- a/templates/bookshelf_templ.go +++ b/templates/bookshelf_templ.go @@ -63,129 +63,181 @@ func BookShelf( } } else { if currentLibraryID == "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } for _, lib := range libraries { if lib.ID == currentLibraryID { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 63, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
= 2) fetchAuthorValues($el)\">
= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\">
= 2) fetchSeriesValues($el)\">
= 2) fetchLanguageValues($el)\">

Saved Filters

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
= 2) fetchAuthorValues($el)\">
= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\">
= 2) fetchSeriesValues($el)\">
= 2) fetchLanguageValues($el)\">

Saved Filters

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, filter := range savedFilters { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(savedFilters) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
No saved filters yet
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
No saved filters yet
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -193,73 +245,73 @@ func BookShelf( if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if errorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 337, Col: 59} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if count > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " Page ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">← Previous Page ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 356, Col: 32} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">Next →") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "

Save Filter

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "

Save Filter

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/library_switcher.templ b/templates/library_switcher.templ index 224e2bd..6a22181 100644 --- a/templates/library_switcher.templ +++ b/templates/library_switcher.templ @@ -11,14 +11,14 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions .. class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500" style="background-color: var(--bg-secondary); color: var(--text-primary);" > - - for _, lib := range libData { - if lib.ID == currentLibraryID { - - } else { - - } + + for _, lib := range libData { + if lib.ID == currentLibraryID { + + } else { + } + } if len(actions) > 0 { diff --git a/templates/library_switcher_templ.go b/templates/library_switcher_templ.go index 74c7220..2c24a48 100644 --- a/templates/library_switcher_templ.go +++ b/templates/library_switcher_templ.go @@ -29,81 +29,120 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ... templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, lib := range libData { if lib.ID == currentLibraryID { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(actions) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -113,12 +152,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ... return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -142,12 +181,12 @@ func DashboardActions() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var6 := templ.GetChildren(ctx) - if templ_7745c5c3_Var6 == nil { - templ_7745c5c3_Var6 = templ.NopComponent + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index 7ace8ba..9854130 100644 --- a/templates/types.go +++ b/templates/types.go @@ -37,6 +37,7 @@ type LibraryData struct { Name string Description string TypeName string + MediaCount int64 } type SeriesCardData struct { diff --git a/templates/utils.go b/templates/utils.go index c0d3ddf..5d3eaaf 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -29,6 +29,16 @@ func ContainsString(slice []string, item string) bool { return false } +// TotalMediaCount sums the MediaCount across the given libraries, +// used to display the total next to the "All Libraries" option. +func TotalMediaCount(libs []LibraryData) int64 { + var total int64 + for _, l := range libs { + total += l.MediaCount + } + return total +} + func uuidToString(id pgtype.UUID) string { if !id.Valid { return "" From 26f695f480799400505e4db4564f3ac0866dc24c Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 12:12:18 -0400 Subject: [PATCH 15/52] fix(opds): correct feed tests referencing non-existent entry fields The OPDS feed test suite did not compile or pass: - TestNewEntry asserted on entry.Creator, but the Entry struct stores the creator under Author.Name (the Atom element). Assert on entry.Author.Name instead. - TestFeedGenerateXML expected / elements, but the Entry struct emits standard Atom and <author><name>. Update the expected substrings to match the actual (correct) output. These are pre-existing assertion errors unrelated to any field being removed; the code under test was already correct. --- internal/opds/feed_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/opds/feed_test.go b/internal/opds/feed_test.go index c26185d..2e7081c 100644 --- a/internal/opds/feed_test.go +++ b/internal/opds/feed_test.go @@ -71,8 +71,8 @@ func TestNewEntry(t *testing.T) { t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title) } - if entry.Creator != "Test Author" { - t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator) + if entry.Author == nil || entry.Author.Name != "Test Author" { + t.Errorf("expected Author.Name to be 'Test Author', got %v", entry.Author) } if entry.Updated != "2023-01-01T00:00:00Z" { @@ -201,8 +201,8 @@ func TestFeedGenerateXML(t *testing.T) { `<title>Test Feed`, ``, `urn:uuid:book-id`, - `Test Book`, - `Test Author`, + `Test Book`, + `Test Author`, `book-uuid-123`, From 9920fd47b9c44543ab27274f3da87799b704d66f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 12:12:33 -0400 Subject: [PATCH 16/52] feat(opds): add OpenSearch pagination metadata and search description Extend the OPDS feed model so clients can page through large catalogs and discover how to search them. Feed changes: - Add the OpenSearch namespace (xmlns:opensearch) to all feeds. - Add optional TotalResults/ItemsPerPage/StartIndex fields, serialized as , and , plus a SetPagination helper. - Add OpenSearchDescription/OpenSearchUrl types and a NewSearchDescription constructor with GenerateXML/GenerateXMLString. This produces the OpenSearch description document (application/opensearchdescription+xml) that OPDS clients like KOReader fetch to learn the {searchTerms} search URL template. These are building blocks; the handlers are wired up in a follow-up commit. Tests cover SetPagination, omission when unset, XML emission of the paging metadata, and OpenSearch description generation/serialization. --- internal/opds/feed.go | 106 +++++++++++++++++++++++++++++++------ internal/opds/feed_test.go | 101 +++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 17 deletions(-) diff --git a/internal/opds/feed.go b/internal/opds/feed.go index 44cb16d..a893e2d 100644 --- a/internal/opds/feed.go +++ b/internal/opds/feed.go @@ -9,15 +9,19 @@ import ( // OPDS 1.2 Feed Structures type Feed struct { - XMLName xml.Name `xml:"feed"` - Xmlns string `xml:"xmlns,attr"` - OpdsNS string `xml:"xmlns:opds,attr"` - DcNS string `xml:"xmlns:dc,attr"` - ID string `xml:"id"` - Title string `xml:"title"` - Updated string `xml:"updated"` - Links []Link `xml:"link"` - Entries []Entry `xml:"entry"` + XMLName xml.Name `xml:"feed"` + Xmlns string `xml:"xmlns,attr"` + OpdsNS string `xml:"xmlns:opds,attr"` + DcNS string `xml:"xmlns:dc,attr"` + OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"` + ID string `xml:"id"` + Title string `xml:"title"` + Updated string `xml:"updated"` + Links []Link `xml:"link"` + TotalResults *int `xml:"opensearch:totalResults,omitempty"` + ItemsPerPage *int `xml:"opensearch:itemsPerPage,omitempty"` + StartIndex *int `xml:"opensearch:startIndex,omitempty"` + Entries []Entry `xml:"entry"` } type Entry struct { @@ -64,17 +68,29 @@ type Category struct { func NewFeed(feedID, title string) *Feed { now := time.Now().Format(time.RFC3339) return &Feed{ - Xmlns: "http://www.w3.org/2005/Atom", - OpdsNS: "http://opds-spec.org/2010/", - DcNS: "http://purl.org/dc/elements/1.1/", - ID: feedID, - Title: title, - Updated: now, - Links: []Link{}, - Entries: []Entry{}, + Xmlns: "http://www.w3.org/2005/Atom", + OpdsNS: "http://opds-spec.org/2010/", + DcNS: "http://purl.org/dc/elements/1.1/", + OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/", + ID: feedID, + Title: title, + Updated: now, + Links: []Link{}, + Entries: []Entry{}, } } +// SetPagination populates the OpenSearch paging metadata (totalResults, +// itemsPerPage, startIndex). startIndex is 1-based to match the page model. +func (f *Feed) SetPagination(totalResults, itemsPerPage, startIndex int) { + tr := totalResults + ipp := itemsPerPage + si := startIndex + f.TotalResults = &tr + f.ItemsPerPage = &ipp + f.StartIndex = &si +} + // AddLink adds a link to the feed func (f *Feed) AddLink(href, linkType, rel string) { f.Links = append(f.Links, Link{ @@ -178,6 +194,62 @@ func (f *Feed) GenerateXMLString() (string, error) { return xml.Header + string(output), nil } +// OpenSearchUrl is a single element in an OpenSearch description. +type OpenSearchUrl struct { + XMLName xml.Name `xml:"Url"` + Type string `xml:"type,attr"` + Template string `xml:"template,attr"` +} + +// OpenSearchDescription is an OpenSearch description document used by OPDS +// clients (e.g. KOReader) to discover how to perform catalog searches. Clients +// fetch this document at the catalog's rel="search" link, then substitute +// {searchTerms} in the Url template to execute a query. +type OpenSearchDescription struct { + XMLName xml.Name `xml:"OpenSearchDescription"` + Xmlns string `xml:"xmlns,attr"` + ShortName string `xml:"ShortName"` + Description string `xml:"Description"` + InputEncoding string `xml:"InputEncoding"` + OutputEncoding string `xml:"OutputEncoding"` + Url OpenSearchUrl `xml:"Url"` +} + +// NewSearchDescription creates an OpenSearch description document whose Url +// template points clients back to the search results endpoint. The template +// must contain the {searchTerms} placeholder. +func NewSearchDescription(shortName, description, template string) *OpenSearchDescription { + return &OpenSearchDescription{ + Xmlns: "http://a9.com/-/spec/opensearch/1.1/", + ShortName: shortName, + Description: description, + InputEncoding: "UTF-8", + OutputEncoding: "UTF-8", + Url: OpenSearchUrl{ + Type: "application/atom+xml;profile=opds-catalog;kind=acquisition", + Template: template, + }, + } +} + +// GenerateXML generates the OpenSearch description XML +func (d *OpenSearchDescription) GenerateXML() ([]byte, error) { + output, err := xml.MarshalIndent(d, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal OpenSearch description: %w", err) + } + return output, nil +} + +// GenerateXMLString generates the OpenSearch description XML as a string +func (d *OpenSearchDescription) GenerateXMLString() (string, error) { + output, err := d.GenerateXML() + if err != nil { + return "", err + } + return xml.Header + string(output), nil +} + // NewErrorFeed creates an error feed func NewErrorFeed(message string) *Feed { feed := NewFeed( diff --git a/internal/opds/feed_test.go b/internal/opds/feed_test.go index 2e7081c..991d87e 100644 --- a/internal/opds/feed_test.go +++ b/internal/opds/feed_test.go @@ -232,6 +232,107 @@ func TestNewErrorFeed(t *testing.T) { } } +func TestFeedSetPagination(t *testing.T) { + feed := NewFeed("urn:uuid:test-id", "Test Feed") + feed.SetPagination(1814, 50, 51) + + if feed.TotalResults == nil || *feed.TotalResults != 1814 { + t.Errorf("expected TotalResults to be 1814, got %v", feed.TotalResults) + } + if feed.ItemsPerPage == nil || *feed.ItemsPerPage != 50 { + t.Errorf("expected ItemsPerPage to be 50, got %v", feed.ItemsPerPage) + } + if feed.StartIndex == nil || *feed.StartIndex != 51 { + t.Errorf("expected StartIndex to be 51, got %v", feed.StartIndex) + } +} + +func TestFeedGenerateXMLPagination(t *testing.T) { + feed := NewFeed("urn:uuid:test-id", "Test Feed") + feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "first") + feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "previous") + feed.AddLink("http://example.com/catalog?page=2", "application/atom+xml", "self") + feed.AddLink("http://example.com/catalog?page=3", "application/atom+xml", "next") + feed.AddLink("http://example.com/catalog?page=37", "application/atom+xml", "last") + feed.SetPagination(1814, 50, 51) + + output, err := feed.GenerateXML() + if err != nil { + t.Fatalf("failed to generate XML: %v", err) + } + outputStr := string(output) + + requiredStrings := []string{ + `xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"`, + `1814`, + `50`, + `51`, + `rel="first"`, + `rel="previous"`, + `rel="next"`, + `rel="last"`, + `page=3`, + } + + for _, required := range requiredStrings { + if !contains(outputStr, required) { + t.Errorf("generated XML missing required string: %s", required) + } + } +} + +func TestFeedGenerateXMLOmitsPaginationWhenUnset(t *testing.T) { + feed := NewFeed("urn:uuid:test-id", "Test Feed") + + output, err := feed.GenerateXML() + if err != nil { + t.Fatalf("failed to generate XML: %v", err) + } + outputStr := string(output) + + if contains(outputStr, "opensearch:totalResults") { + t.Errorf("expected no totalResults when pagination unset, but found it") + } + if contains(outputStr, "opensearch:itemsPerPage") { + t.Errorf("expected no itemsPerPage when pagination unset, but found it") + } +} + +func TestNewSearchDescription(t *testing.T) { + template := "http://example.com/opds/devices/abc/search?q={searchTerms}&token=xyz" + desc := NewSearchDescription("Bookhoard", "Search the library", template) + + if desc.ShortName != "Bookhoard" { + t.Errorf("expected ShortName 'Bookhoard', got '%s'", desc.ShortName) + } + if desc.Url.Template != template { + t.Errorf("expected template '%s', got '%s'", template, desc.Url.Template) + } +} + +func TestSearchDescriptionGenerateXML(t *testing.T) { + template := "http://example.com/opds/devices/abc/search?q={searchTerms}" + desc := NewSearchDescription("Bookhoard", "Search the library", template) + + output, err := desc.GenerateXMLString() + if err != nil { + t.Fatalf("failed to generate XML: %v", err) + } + + requiredStrings := []string{ + ``, + `Bookhoard`, + `= len(substr) && indexOf(s, substr) >= 0 } From 13cc689bff00b72b4b8c69acbdf3f845b02e73b8 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 12:12:51 -0400 Subject: [PATCH 17/52] fix(opds): wire up catalog pagination links and OpenSearch search The device catalog feed was unusable on paged OPDS clients such as KOReader: it sliced results into pages but never advertised how to reach the next page, so clients could only ever fetch the first page (~50 books) and could not search the catalog. GetDeviceCatalog: - Emit the full set of OPDS pagination link relations (self, start, first, previous, next, last) pointing at catalog?page=N&per_page=M, with the device auth token appended for path-based auth. - Emit OpenSearch totalResults/itemsPerPage/startIndex metadata. - Point rel=search at the OpenSearch description (correct MIME type). SearchDeviceCatalog now branches on the q parameter: - No q: return an OpenSearch description document whose Url template contains the {searchTerms} placeholder, so clients can formulate a query. - With q: return the existing acquisition results feed, now including totalResults. A pure addCatalogPaginationLinks helper holds the page/URL logic so it can be unit tested without a database. New handler tests cover middle/first/ last/single/empty pages (correct presence of next/previous) and token appending. Ordering is intentionally left unchanged (created_at DESC, grouped by library). --- internal/handlers/opds.go | 90 ++++++++++++++++++++----- internal/handlers/opds_test.go | 119 +++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 internal/handlers/opds_test.go diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go index a847274..1d23b74 100644 --- a/internal/handlers/opds.go +++ b/internal/handlers/opds.go @@ -67,6 +67,42 @@ func appendToken(url, token string) string { return url + "?token=" + token } +// catalogMediaType is the OPDS media type for an acquisition catalog feed. +const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition" + +// addCatalogPaginationLinks adds OPDS pagination links (self, start, first, +// previous, next, last) and OpenSearch paging metadata (totalResults, +// itemsPerPage, startIndex) to a feed based on the current page position. +// catalogBase is the device catalog URL without query parameters. The token +// (device auth) is appended to every generated link. +func addCatalogPaginationLinks(feed *opds.Feed, catalogBase string, pageNum, perPageNum, totalItems int, token string) { + totalPages := 0 + if totalItems > 0 { + totalPages = (totalItems + perPageNum - 1) / perPageNum + } + startIdx := (pageNum - 1) * perPageNum + + pagedURL := func(page int) string { + return appendToken(fmt.Sprintf("%s?page=%d&per_page=%d", catalogBase, page, perPageNum), token) + } + + // self reflects the current page; start/first point to the first page + feed.AddLink(pagedURL(pageNum), catalogMediaType, "self") + feed.AddLink(pagedURL(1), catalogMediaType, "start") + feed.AddLink(pagedURL(1), catalogMediaType, "first") + if totalPages > 0 { + feed.AddLink(pagedURL(totalPages), catalogMediaType, "last") + } + if pageNum > 1 { + feed.AddLink(pagedURL(pageNum-1), catalogMediaType, "previous") + } + if pageNum < totalPages { + feed.AddLink(pagedURL(pageNum+1), catalogMediaType, "next") + } + + feed.SetPagination(totalItems, perPageNum, startIdx+1) +} + // resolveMimeType returns the mime type for a media item, preferring the stored // mime_type, then format_mimetype, and finally falling back to EPUB. func resolveMimeType(mime, formatMime pgtype.Text) string { @@ -206,14 +242,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { "Bookhoard Library", ) - // Add feed links + // Feed links, including OPDS pagination links (first/previous/next/last) and + // OpenSearch paging metadata (totalResults/itemsPerPage/startIndex). token := h.getAuthToken(c) - catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token) - feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self") - feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start") + catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID) + addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token) + // OpenSearch: the search link points to an OpenSearch description document + // (served by the same /search endpoint when no query is supplied) so that + // OPDS clients like KOReader can discover how to formulate search requests. searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token) - feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search") + feed.AddLink(searchURL, "application/opensearchdescription+xml", "search") // Add entries for _, item := range allItems { @@ -286,16 +325,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { return c.String(http.StatusOK, xmlString) } -// SearchDeviceCatalog searches the OPDS catalog for a device +// SearchDeviceCatalog searches the OPDS catalog for a device. +// +// When no "q" query parameter is supplied it returns an OpenSearch description +// document (application/opensearchdescription+xml) so that OPDS clients such as +// KOReader can discover the search URL template (which contains the +// {searchTerms} placeholder). When "q" is supplied it returns an OPDS +// acquisition feed of matching books. func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error { deviceID := c.Param("deviceId") - query := c.QueryParam("q") - if query == "" { - return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query")) - } - // Get base URLs baseURL, opdsBaseURL, err := h.getBaseURLs(c) if err != nil { @@ -316,12 +356,30 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error { // Get user's visible libraries userID := device.UserID.Bytes - _, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) if err != nil { return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) } + token := h.getAuthToken(c) + + // No query: serve the OpenSearch description document so clients can learn + // the search template (contains the {searchTerms} placeholder). + if query == "" { + searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token) + desc := opds.NewSearchDescription( + "Bookhoard", + "Search the Bookhoard library", + searchURL, + ) + xmlString, err := desc.GenerateXMLString() + if err != nil { + return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate search description")) + } + c.Response().Header().Set("Content-Type", "application/opensearchdescription+xml") + return c.String(http.StatusOK, xmlString) + } + // Search media items allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, @@ -340,12 +398,14 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error { ) // Add feed links - token := h.getAuthToken(c) catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token) - feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start") + feed.AddLink(catalogURL, catalogMediaType, "start") searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token) - feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self") + feed.AddLink(searchURL, catalogMediaType, "self") + + // OpenSearch paging metadata (search results are a single page) + feed.SetPagination(len(allItems), len(allItems), 1) // Add entries (same as catalog) userUUID := uuid.UUID(userID) diff --git a/internal/handlers/opds_test.go b/internal/handlers/opds_test.go new file mode 100644 index 0000000..4fd3e30 --- /dev/null +++ b/internal/handlers/opds_test.go @@ -0,0 +1,119 @@ +package handlers + +import ( + "bookhoard/internal/opds" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rels collects the rel attributes of all links currently on the feed. +func rels(feed *opds.Feed) []string { + out := make([]string, 0, len(feed.Links)) + for _, l := range feed.Links { + out = append(out, l.Rel) + } + return out +} + +func containsRel(feed *opds.Feed, rel string) bool { + for _, l := range feed.Links { + if l.Rel == rel { + return true + } + } + return false +} + +func TestAddCatalogPaginationLinks_MiddlePage(t *testing.T) { + feed := opds.NewFeed("urn:uuid:dev", "Library") + // 1814 items, 50 per page => 37 pages; on page 2 + addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 2, 50, 1814, "tok") + + assert.True(t, containsRel(feed, "self")) + assert.True(t, containsRel(feed, "start")) + assert.True(t, containsRel(feed, "first")) + assert.True(t, containsRel(feed, "last")) + assert.True(t, containsRel(feed, "previous"), "middle page must have previous") + assert.True(t, containsRel(feed, "next"), "middle page must have next") + + // self must point to the current page + var selfHref string + for _, l := range feed.Links { + if l.Rel == "self" { + selfHref = l.Href + } + } + assert.Contains(t, selfHref, "page=2&per_page=50") + assert.Contains(t, selfHref, "token=tok") + + // next must advance the page + var nextHref string + for _, l := range feed.Links { + if l.Rel == "next" { + nextHref = l.Href + } + } + assert.Contains(t, nextHref, "page=3") + + // OpenSearch metadata + require.NotNil(t, feed.TotalResults) + assert.Equal(t, 1814, *feed.TotalResults) + require.NotNil(t, feed.ItemsPerPage) + assert.Equal(t, 50, *feed.ItemsPerPage) + require.NotNil(t, feed.StartIndex) + assert.Equal(t, 51, *feed.StartIndex, "startIndex should be 1-based offset of first item on page 2") +} + +func TestAddCatalogPaginationLinks_FirstPage_NoPrevious(t *testing.T) { + feed := opds.NewFeed("urn:uuid:dev", "Library") + addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 1814, "") + + rels := rels(feed) + assert.NotContains(t, rels, "previous", "first page must not have previous") + assert.Contains(t, rels, "next") +} + +func TestAddCatalogPaginationLinks_LastPage_NoNext(t *testing.T) { + feed := opds.NewFeed("urn:uuid:dev", "Library") + addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 37, 50, 1814, "") + + rels := rels(feed) + assert.NotContains(t, rels, "next", "last page must not have next") + assert.Contains(t, rels, "previous") +} + +func TestAddCatalogPaginationLinks_SinglePage(t *testing.T) { + feed := opds.NewFeed("urn:uuid:dev", "Library") + addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 10, "") + + rels := rels(feed) + assert.NotContains(t, rels, "previous") + assert.NotContains(t, rels, "next") + // still emits self/start/first/last + assert.Contains(t, rels, "self") + assert.Contains(t, rels, "last") +} + +func TestAddCatalogPaginationLinks_EmptyCatalog(t *testing.T) { + feed := opds.NewFeed("urn:uuid:dev", "Library") + addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 0, "") + + rels := rels(feed) + assert.NotContains(t, rels, "next") + assert.NotContains(t, rels, "previous") + assert.NotContains(t, rels, "last", "empty catalog should not advertise a last page") + require.NotNil(t, feed.TotalResults) + assert.Equal(t, 0, *feed.TotalResults) +} + +func TestAddCatalogPaginationLinks_TokenAppended(t *testing.T) { + feed := opds.NewFeed("urn:uuid:dev", "Library") + addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 100, "abc") + + xml, err := feed.GenerateXMLString() + require.NoError(t, err) + assert.True(t, strings.Count(xml, "token=abc") >= 3, "token should be appended to generated links") +} From 1f5b0d01649f5e739377cf5200dba39ec0588c46 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 12:12:58 -0400 Subject: [PATCH 18/52] docs(api): document OPDS pagination links and OpenSearch search Update the OPDS section of the API reference to reflect the now-working catalog: - Document the page/per_page parameters and that paging is driven by the rel=next/previous/first/last links plus OpenSearch paging metadata. - Refresh the example feed XML to show the pagination links, opensearch namespace/elements, and standard Atom /<author> elements. - Document the search endpoint's two modes: OpenSearch description (application/opensearchdescription+xml, no q) and results feed (with q), with an example description document. --- docs/developer/api-reference.md | 48 +++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/docs/developer/api-reference.md b/docs/developer/api-reference.md index 046cd43..916bf11 100644 --- a/docs/developer/api-reference.md +++ b/docs/developer/api-reference.md @@ -986,25 +986,39 @@ GET /opds/devices/{deviceId}/catalog?page={page}&per_page={per_page} - `page` (optional): Page number (default: 1) - `per_page` (optional): Items per page (default: 50, max: 200) +The feed is paginated via standard OPDS link relations. Clients (e.g. KOReader) +walk pages by following the `rel="next"` link until it is absent. OpenSearch +paging metadata (`totalResults`, `itemsPerPage`, `startIndex`) is also included. + **Response** (200 - OPDS 1.2 XML): ```xml <?xml version="1.0" encoding="UTF-8"?> <feed xmlns="http://www.w3.org/2005/Atom" xmlns:opds="http://opds-spec.org/2010/" - xmlns:dc="http://purl.org/dc/elements/1.1/"> + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"> <id>urn:uuid:device-id</id> <title>Bookhoard Library 2026-02-01T12:00:00Z - - - + + + + + + + + + 1814 + 50 + 51 urn:uuid:bookhoard-uuid-123 - The Hobbit - J.R.R. Tolkien + The Hobbit + J.R.R. Tolkien 2026-02-01T10:00:00Z + + Bookhoard + Search the Bookhoard library + UTF-8 + UTF-8 + + +``` + +When called **with** a `q` parameter, **Response** (200 - OPDS 1.2 XML with +search results, including `opensearch:totalResults`). ### List Available Formats From ca8c5924960efba173d32330c0af36774ac4b84a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 13:08:00 -0400 Subject: [PATCH 19/52] feat(book-detail): add interactive half-star rating widget The book detail page only displayed user ratings as static, non-clickable stars. The full rating CRUD stack already existed in the backend (media_ratings table, POST/GET/PUT/DELETE /api/media-items/:id/rating) but nothing in the web UI could create or update a rating. Replace the display-only renderStars output for the user rating with an Alpine.js widget that: - Renders 5 stars, each split into two transparent hit zones so the underlying 1-10 scale maps to half-star precision (left half = x.5, right half = whole star). - Shows a live hover preview via a ratingHover state field. - Saves the rating in place through POST /api/media-items/:id/rating (which upserts) and reflects the value immediately, with no full page reload. - Displays the numeric value (e.g. "3.5 / 5") and a Clear button that issues DELETE to remove the rating. - Reads the server-rendered value from a new data-rating attribute on during the bookDetail component init(). The community rating block is left as a display-only renderStars render since it is imported metadata, not a user rating. templates/book_detail_templ.go is regenerated (also picking up templ v0.3.1020 reformatting of the generated output). --- templates/book_detail.templ | 41 ++- templates/book_detail_templ.go | 456 ++++++++++++++++----------------- web/src/book-detail.ts | 84 ++++++ 3 files changed, 343 insertions(+), 238 deletions(-) diff --git a/templates/book_detail.templ b/templates/book_detail.templ index 2b6b639..70c52a5 100644 --- a/templates/book_detail.templ +++ b/templates/book_detail.templ @@ -19,7 +19,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) { - + @Header(user, "/media/{ uuidToString(book.ID) }")
@@ -103,13 +103,42 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
-
+
- @templ.Raw(renderStars(getBookRating(book.Rating))) - - - ({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5) + + + + + + +
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 { diff --git a/templates/book_detail_templ.go b/templates/book_detail_templ.go index 89d5d95..1a1282c 100644 --- a/templates/book_detail_templ.go +++ b/templates/book_detail_templ.go @@ -58,7 +58,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FormatGroup) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 22, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 22, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -71,13 +71,26 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(book.LibraryID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 22, Col: 142} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 22, Col: 145} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" data-rating=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", getBookRating(book.Rating))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 22, Col: 207} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -85,156 +98,135 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.CoverImagePath.Valid && book.CoverImagePath.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" alt=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 39, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"w-64 h-96 object-cover rounded-lg shadow-xl\" onerror=\"this.src='/static/placeholder-book.svg'\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"{") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"{") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 54, Col: 89} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.Author.Valid && book.Author.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

by ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

by ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author.String) + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author.String) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 57, Col: 31} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"px-6 py-3 rounded-lg font-semibold\" style=\"background-color: var(--accent); color: white;\">📖 Read Now") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.ActiveConflict != nil || book.ReadingProgress != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templ.Raw(renderStars(getBookRating(book.Rating))).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " (") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 111, Col: 71} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " / 5)
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
Community Rating: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
Community Rating: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -242,210 +234,210 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 123, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 152, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.Series.Valid && book.Series.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"px-3 py-1 rounded-full text-sm font-semibold inline-block hover:opacity-80 transition-opacity\" style=\"background-color: var(--accent); color: white; text-decoration: none;\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book.Series.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 136, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 165, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "#") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "#") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesNumber.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 138, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 167, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
📖 ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
📖 ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.ReadingDirection.String)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 150, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 179, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.AgeRating.Valid && book.AgeRating.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.AgeRating.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 162, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 191, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "B&W") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "B&W") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.StoryArc.Valid && book.StoryArc.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "📚 ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "📚 ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.StoryArc.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 180, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 209, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(book.Tags) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.Description.Valid && book.Description.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

Synopsis

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

Synopsis

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -455,17 +447,17 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "

Comic Summary

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "

Comic Summary

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -475,760 +467,760 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.MetadataNotes.Valid && book.MetadataNotes.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "

Metadata Notes

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "

Metadata Notes

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.MetadataNotes.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 230, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 259, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.ReadingProgress != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "

Reading Progress

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

Reading Progress

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.ActiveConflict != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "

⚠️ Progress conflict detected - Click \"Sync Progress\" to review and resolve

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "

⚠️ Progress conflict detected - Click \"Sync Progress\" to review and resolve

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "

Progress

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\">

Progress

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 265, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 294, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "%

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "%

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "

Page

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "

Page

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.CurrentPage.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 271, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 300, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " / ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var25 string templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.TotalPages.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 271, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 300, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.ReadingProgress.LastReadAt.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "

Last Read

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "

Last Read

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 277, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 306, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.ReadingProgress.LastSyncSource.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "

Source

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "

Source

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var27 string templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 283, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 312, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "

Metadata

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "

Metadata

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.Publisher.Valid && book.Publisher.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "

Publisher

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "

Publisher

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(book.Publisher.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 300, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 329, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.DatePublished.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "

Published

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "

Published

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 306, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 335, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.Isbn.Valid && book.Isbn.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "

Isbn

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "

Isbn

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 312, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 341, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.Language.Valid && book.Language.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "

Language

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "

Language

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(book.Language.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 318, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 347, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.Edition.Valid && book.Edition.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "

Edition

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "

Edition

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var32 string templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Edition.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 324, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 353, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.PageCount.Valid && book.PageCount.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "

Pages

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

Pages

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var33 string templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 330, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 359, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.Genre.Valid && book.Genre.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

Genre

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "

Genre

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var34 string templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(book.Genre.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 336, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 365, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "

Series Count

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "

Series Count

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var35 string templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 343, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 372, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " items

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " items

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.Volume.Valid && book.Volume.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "

Volume

Vol. ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "

Volume

Vol. ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var36 string templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 349, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 378, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.Imprint.Valid && book.Imprint.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "

Imprint

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "

Imprint

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var37 string templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(book.Imprint.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 355, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 384, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "

Copyright Year

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "

Copyright Year

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var38 string templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 361, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 390, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "

Manga Type

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "

Manga Type

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var39 string templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " ")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 368, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 397, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.ScanInformation.Valid && book.ScanInformation.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "

Scan Info

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

Scan Info

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var40 string templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 374, Col: 94} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 403, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(book.AlternateInfo) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

Alternate Series

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "

Alternate Series

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var41 string templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(string(book.AlternateInfo)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 380, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 409, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "

Alternate Series

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "

Alternate Series

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var42 string templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 386, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 415, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(book.Contributors) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "

Contributors

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

Contributors

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var43 string templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", ")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 392, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 421, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "

Format

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "

Format

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var44 string templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 398, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 427, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.FileSize.Valid && book.FileSize.Int64 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "

File Size

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "

File Size

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var45 string templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 403, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 432, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "

External Links

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "

External Links

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.GoodreadsID.Valid && book.GoodreadsID.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "📚 Goodreads ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">📚 Goodreads ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "📚 Goodreads ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">📚 Goodreads ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "📖 Open Library ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">📖 Open Library ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "📖 Open Library ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">📖 Open Library ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "🔍 Google Books ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">🔍 Google Books ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "🔍 Google Books ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">🔍 Google Books ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.Asin.Valid && book.Asin.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "🛒 Amazon ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">🛒 Amazon ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if book.Isbn.Valid && book.Isbn.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "🛒 Amazon ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">🛒 Amazon ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.WebUrl.Valid && book.WebUrl.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "🔗 ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">🔗 ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var55 string templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(getDomainName(book.WebUrl.String)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 504, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 533, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(book.Collections) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "

Collections

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "

Collections

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, col := range book.Collections { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\" class=\"px-3 py-2 rounded-lg border flex items-center gap-2 hover:opacity-80 transition-opacity\" style=\"border-color: { col.Color.String }; background-color: var(--bg-primary); text-decoration: none;\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var57 string templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 525, Col: 69} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 554, Col: 69} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var58 string templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 526, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 555, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1248,7 +1240,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index b5c8117..e97f01c 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -97,6 +97,9 @@ interface MetadataEditorState { coverFile: Blob | null; coverAction: string; saving: boolean; + userRating: number; + ratingHover: number; + ratingSaving: boolean; toggleSection(section: string): void; showMetadataEditor(): void; hideMetadataEditor(): void; @@ -104,6 +107,10 @@ interface MetadataEditorState { generateCover(): Promise; removeCover(): void; saveMetadata(): Promise; + starFill(i: number): string; + ratingText(): string; + setRating(value: number): Promise; + clearRating(): Promise; resolveConflict(conflictId: string, winner: string): Promise; } @@ -136,6 +143,9 @@ Alpine.data("bookDetail", () => { coverFile: null as Blob | null, coverAction: "keep", saving: false, + userRating: 0, + ratingHover: 0, + ratingSaving: false, editorTags: initialTags, tagSearch: "", tagSuggestions: [] as TagSuggestion[], @@ -178,6 +188,9 @@ Alpine.data("bookDetail", () => { }, init() { + const ratingAttr = document.body.getAttribute("data-rating"); + this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0; + const link = document.getElementById("back-link"); if (!link) return; const storageKey = "book_detail_back"; @@ -207,6 +220,77 @@ Alpine.data("bookDetail", () => { this.openSections[section] = !this.openSections[section]; }, + starFill(i: number): string { + const display = this.ratingHover || this.userRating; + if (i * 2 <= display) { + return "color: var(--accent);"; + } else if (i * 2 - 1 === display) { + return "background: linear-gradient(90deg, var(--accent) 50%, var(--text-secondary) 50%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;"; + } + return "color: var(--text-secondary);"; + }, + + ratingText(): string { + if (this.userRating === 0) return "(not rated)"; + return `(${(this.userRating / 2).toFixed(1)} / 5)`; + }, + + async setRating(value: number) { + if (this.ratingSaving) return; + this.ratingSaving = true; + const mediaId = getMediaId(); + try { + const resp = await fetch(`/api/media-items/${mediaId}/rating`, { + method: "POST", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: value }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || "Failed to save rating"); + } + this.userRating = value; + this.ratingHover = 0; + showToast("Rating saved", "success"); + } catch (e) { + showToast( + e instanceof Error ? e.message : "Failed to save rating", + "error", + ); + } finally { + this.ratingSaving = false; + } + }, + + async clearRating() { + if (this.ratingSaving) return; + this.ratingSaving = true; + const mediaId = getMediaId(); + try { + const resp = await fetch(`/api/media-items/${mediaId}/rating`, { + method: "DELETE", + headers: { Authorization: getAuthHeader() }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || "Failed to clear rating"); + } + this.userRating = 0; + this.ratingHover = 0; + showToast("Rating cleared", "success"); + } catch (e) { + showToast( + e instanceof Error ? e.message : "Failed to clear rating", + "error", + ); + } finally { + this.ratingSaving = false; + } + }, + handleCoverUpload(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; From c2f72ca785f4931b64b8df2276308c041fc99a2b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 13:08:14 -0400 Subject: [PATCH 20/52] docs(bruno): correct rating scale and endpoint paths The Bruno collection docs mislabeled the rating system and referenced endpoints that do not exist. - Update Media Rating.yml: the rating value is a 1-10 integer scale (displayed as 1-5 stars with half-star precision), not "typically 1-5". - opencollection.yml: the rating routes live under /api/media-items/:id/rating (not /api/ratings/:media_id), GET returns null (not 0) when unrated, and document the PUT upsert route. Correct the scale to 1-10 here as well. --- bruno/media-items/Update Media Rating.yml | 2 +- bruno/opencollection.yml | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bruno/media-items/Update Media Rating.yml b/bruno/media-items/Update Media Rating.yml index 179fca8..765a149 100644 --- a/bruno/media-items/Update Media Rating.yml +++ b/bruno/media-items/Update Media Rating.yml @@ -47,7 +47,7 @@ docs: |- - `id` (string, required): Media item UUID **Request Body:** - - `rating` (number, required): Rating value (typically 1-5) + - `rating` (number, required): Rating value (1-10 integer scale; displayed as 1-5 stars with half-star precision) - `review` (string, optional): Review text **Response:** Updated rating object diff --git a/bruno/opencollection.yml b/bruno/opencollection.yml index 1318a1c..40b11a6 100644 --- a/bruno/opencollection.yml +++ b/bruno/opencollection.yml @@ -83,9 +83,10 @@ docs: - **Update Highlight**: PUT /api/highlights/:id - Update highlight - **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight Ratings (All Users) - - **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 if unrated) - - **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision) - - **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating + - **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated) + - **Create/Update Rating**: POST /api/media-items/:id/rating - Rate media item (1-10 scale, displayed as 1-5 stars with half-star precision). POST upserts; PUT also available. + - **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert) + - **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating Collections (All Users) - **List Collections**: GET /api/collections - Get user's collections - **Get Collection**: GET /api/collections/:id - Collection details with media items From 33c69e7c71894243ca2a6c471758bef651e837fd Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 13:08:25 -0400 Subject: [PATCH 21/52] chore(templates): regenerate stale book_detail_modals templ output Running `templ generate` to pick up the book_detail changes also resynced book_detail_modals_templ.go, whose committed output was stale relative to its source. The regeneration (templ v0.3.1020) reformats boolean attribute rendering (e.g. `selected`) via templ.ResolveAttributeValue and reflects pre-existing source additions such as id/for label associations. No source (.templ) change in this file; generated output only. --- templates/book_detail_modals_templ.go | 262 ++++++++++++++------------ 1 file changed, 143 insertions(+), 119 deletions(-) diff --git a/templates/book_detail_modals_templ.go b/templates/book_detail_modals_templ.go index f5c40ca..3b7a760 100644 --- a/templates/book_detail_modals_templ.go +++ b/templates/book_detail_modals_templ.go @@ -737,214 +737,238 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\">
Unknown
No
Yes
Auto
Left to Right
Right to Left
Vertical
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From bf83492bf7dacf5408030692e056acc4a53a6d11 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 13:31:18 -0400 Subject: [PATCH 22/52] feat(book-detail): add Mark as Read / Unread toggle button The book detail page had no way to mark a book finished or reset its read state from the UI. Reading state is modelled by reading_progress alone, where 'read' is the canonical signal percentage >= 1.0 (used by the dashboard Recently Read collection, analytics, and sync priority). Add a single toggle button in the action row (after Read Now) whose label is server-rendered from completion state: - not read -> "Mark as Read" -> PUT /api/media-items/:id/progress { percentage: 1.0 } - read -> "Mark as Unread" -> DELETE /api/media-items/:id/progress Mark as Unread cannot use PUT { percentage: 0 }: the progress handler silently ignores percentage < 0.005 when existing progress > 0.01 (internal/handlers/media.go anti-regression guard), so DELETE is the only reliable reset. If the book has an active sync mismatch (an unresolved sync_conflicts row), the toggle resolves it first via POST /api/conflicts/:id/resolve before writing progress. Order matters: resolving sets resolved_at, arming the 10-minute HasRecentConflictResolution suppression window so the subsequent progress write does not spawn a brand-new conflict. The resolve winner is any valid source key from the conflict data (prefers "web"); it does not affect the final state, which the progress write sets. A 400 "already resolved" response is tolerated. Notes, highlights, and ratings are independent of reading_progress (they reference media_items, not progress) and are never affected by the toggle. After toggling the page reloads so the progress card, Sync Progress button, and conflict banner re-render server-side. - templates/utils.go: add conflictWinnerSource and conflictID helpers. - templates/book_detail.templ: data-conflict-id/winner on and the toggle button. - web/src/book-detail.ts: toggleRead() + conflictId/conflictWinner/ readSaving state (read from in init()). - templates/book_detail_templ.go regenerated. --- templates/book_detail.templ | 30 +- templates/book_detail_templ.go | 1105 +++++++++++++++++--------------- templates/utils.go | 33 + web/src/book-detail.ts | 86 +++ 4 files changed, 721 insertions(+), 533 deletions(-) diff --git a/templates/book_detail.templ b/templates/book_detail.templ index 70c52a5..c32af59 100644 --- a/templates/book_detail.templ +++ b/templates/book_detail.templ @@ -19,7 +19,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) { - + @Header(user, "/media/{ uuidToString(book.ID) }")
@@ -67,6 +67,34 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) { > 📖 Read Now + + if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 { + + } else { + + } if book.ActiveConflict != nil || book.ReadingProgress != nil { ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if book.ActiveConflict != nil || book.ReadingProgress != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.ActiveConflict != nil || book.ReadingProgress != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
Community Rating: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
Community Rating: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -234,210 +275,210 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 152, Col: 66} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.Series.Valid && book.Series.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book.Series.String) + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 165, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 180, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "#") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesNumber.Int32) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 167, Col: 36} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
📖 ") + if book.Series.Valid && book.Series.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "#") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesNumber.Int32) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 195, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if book.AgeRating.Valid && book.AgeRating.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.AgeRating.String) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 191, Col: 32} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "B&W") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.StoryArc.Valid && book.StoryArc.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "📚 ") + if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
📖 ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.StoryArc.String) + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.ReadingDirection.String)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 209, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 207, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.AgeRating.Valid && book.AgeRating.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.AgeRating.String) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 219, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "B&W") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.StoryArc.Valid && book.StoryArc.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "📚 ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.StoryArc.String) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 237, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(book.Tags) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, tag := range book.Tags { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" class=\"px-2 py-1 rounded-full text-xs font-semibold hover:opacity-80 transition-opacity\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(tag) + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(tag) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 222, Col: 15} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 250, Col: 15} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.Description.Valid && book.Description.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

Synopsis

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "

Synopsis

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -447,17 +488,17 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "

Comic Summary

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "

Comic Summary

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -467,323 +508,281 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.MetadataNotes.Valid && book.MetadataNotes.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "

Metadata Notes

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.MetadataNotes.String) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 259, Col: 34} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.ReadingProgress != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

Reading Progress

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.ActiveConflict != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "

⚠️ Progress conflict detected - Click \"Sync Progress\" to review and resolve

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "

Progress

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "

Metadata Notes

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100)) + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(book.MetadataNotes.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 294, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 287, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "%

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.ReadingProgress != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "

Reading Progress

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.ActiveConflict != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "

⚠️ Progress conflict detected - Click \"Sync Progress\" to review and resolve

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

Progress

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 322, Col: 77} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "%

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "

Page

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.CurrentPage.Int32) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 300, Col: 52} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " / ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.TotalPages.Int32) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 300, Col: 96} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if book.ReadingProgress.LastReadAt.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "

Last Read

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "

Page

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone)) + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.CurrentPage.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 306, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 328, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if book.ReadingProgress.LastSyncSource.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "

Source

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String) + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.TotalPages.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 312, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 328, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + if book.ReadingProgress.LastReadAt.Valid { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "

Last Read

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 334, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if book.ReadingProgress.LastSyncSource.Valid { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "

Source

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 340, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "

Metadata

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "

Metadata

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.Publisher.Valid && book.Publisher.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "

Publisher

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(book.Publisher.String) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 329, Col: 34} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if book.DatePublished.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "

Published

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 335, Col: 57} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if book.Isbn.Valid && book.Isbn.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "

Isbn

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "

Publisher

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var30 string - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String) + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.Publisher.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 341, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 357, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.Language.Valid && book.Language.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "

Language

") + if book.DatePublished.Valid { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "

Published

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(book.Language.String) + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 347, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 363, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.Edition.Valid && book.Edition.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "

Edition

") + if book.Isbn.Valid && book.Isbn.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "

Isbn

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Edition.String) + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 353, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 369, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.PageCount.Valid && book.PageCount.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

Pages

") + if book.Language.Valid && book.Language.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "

Language

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32) + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(book.Language.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 359, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 375, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.Genre.Valid && book.Genre.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "

Genre

") + if book.Edition.Valid && book.Edition.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

Edition

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(book.Genre.String) + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(book.Edition.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 365, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 381, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "

Series Count

") + if book.PageCount.Valid && book.PageCount.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "

Pages

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32) + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 372, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 387, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " items

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.Volume.Valid && book.Volume.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "

Volume

Vol. ") + if book.Genre.Valid && book.Genre.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "

Genre

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32) + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(book.Genre.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 378, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 393, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { @@ -794,57 +793,57 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ return templ_7745c5c3_Err } } - if book.Imprint.Valid && book.Imprint.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "

Imprint

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "

Series Count

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(book.Imprint.String) + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 384, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 400, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " items

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "

Copyright Year

") + if book.Volume.Valid && book.Volume.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "

Volume

Vol. ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var38 string - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32) + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 390, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 406, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "

Manga Type

") + if book.Imprint.Valid && book.Imprint.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "

Imprint

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " ")) + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(book.Imprint.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 397, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 412, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { @@ -855,15 +854,15 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ return templ_7745c5c3_Err } } - if book.ScanInformation.Valid && book.ScanInformation.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

Scan Info

") + if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

Copyright Year

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String) + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 403, Col: 94} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 418, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { @@ -874,353 +873,395 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ return templ_7745c5c3_Err } } - if len(book.AlternateInfo) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "

Alternate Series

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

Manga Type

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(string(book.AlternateInfo)) + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " ")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 409, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 425, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "

Alternate Series

") + if book.ScanInformation.Valid && book.ScanInformation.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "

Scan Info

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries) + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 415, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 431, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if len(book.Contributors) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

Contributors

") + if len(book.AlternateInfo) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "

Alternate Series

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", ")) + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(string(book.AlternateInfo)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 421, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 437, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "

Format

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "

Alternate Series

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 443, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - var templ_7745c5c3_Var44 string - templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 427, Col: 32} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.FileSize.Valid && book.FileSize.Int64 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "

File Size

") + if len(book.Contributors) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "

Contributors

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64)) + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", ")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 432, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 449, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "

Format

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 455, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.FileSize.Valid && book.FileSize.Int64 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "

File Size

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 460, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "

External Links

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "

External Links

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.GoodreadsID.Valid && book.GoodreadsID.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "📚 Goodreads ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "📚 Goodreads ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "📖 Open Library ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">📚 Goodreads ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "📖 Open Library ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">📚 Goodreads ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "🔍 Google Books ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">📖 Open Library ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "🔍 Google Books ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">📖 Open Library ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.Asin.Valid && book.Asin.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "🛒 Amazon ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">🔍 Google Books ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else if book.Isbn.Valid && book.Isbn.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "🛒 Amazon ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">🔍 Google Books ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.WebUrl.Valid && book.WebUrl.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "🔗 ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">🛒 Amazon ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var55 string - templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(getDomainName(book.WebUrl.String)) + } else if book.Isbn.Valid && book.Isbn.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">🛒 Amazon ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if len(book.Collections) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "

Collections

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, col := range book.Collections { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-sm hover:underline flex items-center gap-1\" style=\"color: var(--accent);\">🔗 ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var57 string - templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon.String) + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(getDomainName(book.WebUrl.String)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 554, Col: 69} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 561, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var58 string - templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(book.Collections) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "

Collections

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, col := range book.Collections { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "\" class=\"px-3 py-2 rounded-lg border flex items-center gap-2 hover:opacity-80 transition-opacity\" style=\"border-color: { col.Color.String }; background-color: var(--bg-primary); text-decoration: none;\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var59 string + templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon.String) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 582, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var60 string + templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 583, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1240,7 +1281,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/utils.go b/templates/utils.go index 5d3eaaf..da9bc71 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -2,9 +2,11 @@ package templates import ( "bookhoard/internal/database" + "bookhoard/internal/handlers" "encoding/json" "fmt" "net/url" + "sort" "strings" "time" @@ -156,6 +158,37 @@ func getBookRating(rating *database.MediaRatings) int32 { return 0 } +// conflictWinnerSource returns a valid source key from a conflict's +// conflict_data map, to pass as the "winner" when resolving it. It prefers +// "web" (since the user is acting via the web UI) and otherwise falls back to +// the lexicographically smallest key. The chosen winner does not affect the +// final read/unread state, which is set by a subsequent progress write; it only +// needs to be a key present in the conflict data so the resolve endpoint +// accepts it and arms its 10-minute suppression window. +func conflictWinnerSource(c *handlers.ConflictDetailResponse) string { + if c == nil || len(c.ConflictData) == 0 { + return "" + } + if _, ok := c.ConflictData["web"]; ok { + return "web" + } + keys := make([]string, 0, len(c.ConflictData)) + for k := range c.ConflictData { + keys = append(keys, k) + } + sort.Strings(keys) + return keys[0] +} + +// conflictID returns the active conflict's ID, or "" when there is none. Used +// to render a data-conflict-id attribute the frontend can read. +func conflictID(c *handlers.ConflictDetailResponse) string { + if c == nil { + return "" + } + return c.ID +} + // getAlternateSeries extracts the alternate series name from JSONB data func getAlternateSeries(data []byte) string { if len(data) == 0 { diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index e97f01c..cf91f2e 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -100,6 +100,9 @@ interface MetadataEditorState { userRating: number; ratingHover: number; ratingSaving: boolean; + conflictId: string; + conflictWinner: string; + readSaving: boolean; toggleSection(section: string): void; showMetadataEditor(): void; hideMetadataEditor(): void; @@ -111,6 +114,7 @@ interface MetadataEditorState { ratingText(): string; setRating(value: number): Promise; clearRating(): Promise; + toggleRead(read: boolean): Promise; resolveConflict(conflictId: string, winner: string): Promise; } @@ -146,6 +150,9 @@ Alpine.data("bookDetail", () => { userRating: 0, ratingHover: 0, ratingSaving: false, + conflictId: "", + conflictWinner: "", + readSaving: false, editorTags: initialTags, tagSearch: "", tagSuggestions: [] as TagSuggestion[], @@ -191,6 +198,12 @@ Alpine.data("bookDetail", () => { const ratingAttr = document.body.getAttribute("data-rating"); this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0; + const conflictId = document.body.getAttribute("data-conflict-id"); + const conflictWinner = document.body.getAttribute("data-conflict-winner"); + this.conflictId = conflictId && conflictId !== "null" ? conflictId : ""; + this.conflictWinner = + conflictWinner && conflictWinner !== "null" ? conflictWinner : ""; + const link = document.getElementById("back-link"); if (!link) return; const storageKey = "book_detail_back"; @@ -291,6 +304,79 @@ Alpine.data("bookDetail", () => { } }, + async toggleRead(read: boolean) { + if (this.readSaving) return; + this.readSaving = true; + const mediaId = getMediaId(); + try { + // Clear any active sync conflict first. Resolving arms a 10-minute + // suppression window so the progress write below does not spawn a new + // conflict. The winner only needs to be a valid source key; the final + // read/unread state is set by the progress write that follows. + if (this.conflictId && this.conflictWinner) { + const cr = await fetch( + `/api/conflicts/${this.conflictId}/resolve`, + { + method: "POST", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ winner: this.conflictWinner }), + }, + ); + // 400 means it was already resolved - treat as no conflict. + if (!cr.ok && cr.status !== 400) { + const err = await cr.json().catch(() => ({})); + throw new Error( + err.error || err.message || "Failed to clear sync conflict", + ); + } + } + + if (read) { + // Mark as Read: PUT percentage 1.0. (Cannot PUT 0 to unread - the + // server silently ignores percentage < 0.005 when progress > 0.01.) + const resp = await fetch(`/api/media-items/${mediaId}/progress`, { + method: "PUT", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ percentage: 1.0 }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || "Failed to mark as read"); + } + } else { + // Mark as Unread: DELETE the progress row. Notes, highlights and + // ratings are independent and are NOT affected. + const resp = await fetch(`/api/media-items/${mediaId}/progress`, { + method: "DELETE", + headers: { Authorization: getAuthHeader() }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || "Failed to mark as unread"); + } + } + + showToast( + read ? "Marked as read" : "Marked as unread", + "success", + ); + setTimeout(() => window.location.reload(), 500); + } catch (e) { + showToast( + e instanceof Error ? e.message : "Failed to update read state", + "error", + ); + } finally { + this.readSaving = false; + } + }, + handleCoverUpload(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; From 5ac407057e93c48acab4d33796285c8d11ad75bc Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 14:55:00 -0400 Subject: [PATCH 23/52] fix(search): link results to book detail page and add cover thumbnails Search results navigated to /bookshelf with no filters instead of the selected book's page. Results now link to /media/:id and display cover thumbnails, with cover URLs resolved server-side via ResolveMediaURL. Removes the dead selectedBook localStorage plumbing. --- internal/handlers/media.go | 4 ++++ web/src/search.ts | 30 ++++++++++-------------------- web/src/storage.ts | 10 ---------- 3 files changed, 14 insertions(+), 30 deletions(-) diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 9688873..6618b09 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -1791,6 +1791,10 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error { "results": []interface{}{}, }) } + for i := range results { + resolved := utils.ResolveMediaURL(results[i].LibraryID, results[i].CoverImagePath) + results[i].CoverImagePath = pgtype.Text{String: resolved, Valid: resolved != ""} + } return c.JSON(http.StatusOK, results) } diff --git a/web/src/search.ts b/web/src/search.ts index 83dffad..4719c89 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -1,5 +1,4 @@ import { Alpine } from "./alpine"; -import { setSelectedLibrary } from "./storage"; let searchInputTimeout: ReturnType | null = null; const SEARCH_DEBOUNCE_MS = 300; @@ -190,12 +189,6 @@ function showSearchResults(results: MediaItemSummary[], query: string, activeId? searchResults.dataset.selectedIndex = "-1"; - const libraryIconMap: Record = { - ebooks: "📚", - comics: "📖", - manga: "🗾", - }; - let html = `

@@ -206,19 +199,23 @@ function showSearchResults(results: MediaItemSummary[], query: string, activeId? `; results.forEach((item, index) => { - const icon = libraryIconMap[item.library_type_name] || "📁"; const titleHtml = highlightMatch(item.title, query); const authorHtml = item.author ? highlightMatch(item.author, query) : ""; + const coverUrl = item.cover_image_path || "/static/placeholder-book.svg"; html += `

- +
-
${icon}
+
+ ${searchEscapeHtml(item.title)} +

${titleHtml} @@ -323,15 +320,8 @@ function searchEscapeHtml(text: string): string { return div.innerHTML; } -function selectLibraryAndBook(libraryId: string, bookId: string): void { - setSelectedLibrary(libraryId); - localStorage.setItem("selectedBook", bookId); - hideSearchResults(); -} - -export { selectLibraryAndBook, initializeSearch }; +export { initializeSearch }; Alpine.data("search", () => ({ - selectLibraryAndBook, initializeSearch, })); diff --git a/web/src/storage.ts b/web/src/storage.ts index 956bc8a..45c1f70 100644 --- a/web/src/storage.ts +++ b/web/src/storage.ts @@ -45,14 +45,6 @@ function setSelectedLibrary(libraryId: string): void { document.cookie = `selectedLibrary=${encodeURIComponent(value)};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`; } -function getSelectedBook(): string | null { - return localStorage.getItem("selectedBook"); -} - -function setSelectedBook(bookId: string): void { - localStorage.setItem("selectedBook", bookId); -} - function clearAll(): void { localStorage.clear(); } @@ -61,14 +53,12 @@ export { ALL_LIBRARIES, clearAll, getRefreshToken, - getSelectedBook, getSelectedLibrary, getTheme, getToken, removeRefreshToken, removeToken, setRefreshToken, - setSelectedBook, setSelectedLibrary, setTheme, setToken From 451aa48aecc97394cbd20891669fe84e51b8a3af Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 15:40:27 -0400 Subject: [PATCH 24/52] ci(release): auto-generate release notes via git-cliff Replace the image-only tag pipeline with a full release workflow that also publishes a Gitea Release whose body is the annotated tag's message, generated from Conventional Commits by git-cliff. No hand-written release notes are required. - cliff.toml: group commits (Features, Bug Fixes, Refactor, Documentation, Tests, Miscellaneous Tasks) with scopes and short-SHA links; emit only the current tag's section rather than the full history. - .gitea/workflows/release.yml: tag-driven. Reads the release body from the annotated tag (git tag -l --format), so the tag message and release body are a single source of truth. Idempotent create/PATCH; prints the Gitea API error body on failure so a 403 names the missing token scope instead of failing silently. Adds a workflow_dispatch tag input so manual re-runs target the right tag instead of the default branch. - Makefile: release VERSION=vX.Y.Z generates notes via git cliff --latest against a throwaway tag, then creates an annotated tag with --cleanup=verbatim so the markdown group headers are preserved (git's default cleanup strips lines starting with "#"). - release: project-attached wrapper accepting a positional version arg (./release 0.3.0 or ./release v0.3.0) and auto-prefixing v, for ergonomic one-command releases. --- .gitea/workflows/release.yml | 72 ++++++++++++++++++++++++++++++++++-- Makefile | 29 ++++++++++++++- cliff.toml | 37 ++++++++++++++++++ release | 15 ++++++++ 4 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 cliff.toml create mode 100755 release diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 80c784f..46f7699 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,13 +1,21 @@ name: Release -# Builds and publishes the Bookhoard container image to the Gitea container registry. -# Triggered ONLY by a version tag push (pushing to main does nothing), so work-in-progress -# commits never ship. Each release publishes two image tags: the version and "latest". +# Publishes the Bookhoard container image to the Gitea container registry AND +# creates a Gitea Release whose body is the annotated tag's message (generated +# locally by `make release VERSION=...` via git-cliff). Triggered by a version +# tag push, or manually via workflow_dispatch with a tag. Pushing to main does +# nothing, so work-in-progress commits never ship. Each release publishes two +# image tags: the version (e.g. v0.3.0) and "latest". on: push: tags: - 'v*' workflow_dispatch: + inputs: + tag: + description: 'Tag to release (e.g. v0.3.0)' + required: true + type: string jobs: build-and-push: @@ -15,9 +23,17 @@ jobs: permissions: contents: read packages: write + env: + # Resolve the target tag for both triggers: explicit input on manual + # dispatch, otherwise the pushed tag ref. + TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }} steps: - name: Checkout uses: actions/checkout@v4 + with: + # Full history ensures the tag annotation (the release notes) is present. + fetch-depth: 0 + ref: ${{ gitea.event.inputs.tag || gitea.ref }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -40,5 +56,53 @@ jobs: # Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml; # pin or roll back by setting IMAGE_TAG in .env. tags: | - git.linuxhg.com/bookhoard/bookhoard:${{ gitea.ref_name }} + git.linuxhg.com/bookhoard/bookhoard:${{ env.TAG }} git.linuxhg.com/bookhoard/bookhoard:latest + + - name: Create Gitea Release + env: + # REGISTRY_TOKEN is reused for release creation because Gitea's auto + # GITHUB_TOKEN cannot create releases on this instance. The PAT must + # carry write:repository scope. Idempotent: re-runs update an existing + # release for this tag instead of failing with 409. On any HTTP error + # the API response body is printed so a 403 names the missing scope. + TOKEN: ${{ secrets.REGISTRY_TOKEN }} + REPO: ${{ gitea.repository }} + run: | + set -euo pipefail + : "${TAG:?TAG is required}" + API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases" + AUTH="Authorization: token ${TOKEN}" + # Release body = the annotated tag's message (the git-cliff notes). + BODY="$(git tag -l --format='%(contents)' "${TAG}")" + + # Tags containing a '-' (e.g. v0.3.0-rc1) are published as pre-releases. + PRE="false"; case "${TAG}" in *-*) PRE="true";; esac + + PAYLOAD=$(jq -n \ + --arg t "${TAG}" --arg n "${TAG}" --arg b "${BODY}" --argjson p "${PRE}" \ + '{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}') + + # POST/PATCH the release, surfacing Gitea's error message on failure + # (e.g. "token does not have write scope") instead of failing silently. + api_call() { + local method="$1" url="$2" resp code rbody + resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \ + -H "${AUTH}" -H "Content-Type: application/json" \ + -d "${PAYLOAD}" "${url}")" + code="$(printf '%s' "${resp}" | tail -n1)" + rbody="$(printf '%s' "${resp}" | sed '$d')" + if [ "${code}" -ge 400 ]; then + echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2 + return 1 + fi + } + + EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)" + if [ -n "${EXISTING_ID}" ]; then + api_call PATCH "${API}/${EXISTING_ID}" + echo "Updated existing release id=${EXISTING_ID} for ${TAG}" + else + api_call POST "${API}" + echo "Created new release for ${TAG}" + fi diff --git a/Makefile b/Makefile index cea32de..c986827 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick +.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick release # Include .env file for environment variables (single source of truth) # Ignore if .env doesn't exist yet @@ -44,6 +44,9 @@ help: @echo "Verification:" @echo " make verify-guidelines - Run comprehensive guidelines check" @echo " make verify-quick - Run quick guidelines check" + @echo "" + @echo "Release:" + @echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)" # Run unit tests locally (fast, no containers) test: @@ -158,3 +161,27 @@ verify-guidelines: verify-quick: @echo "Running quick project guidelines verification..." @./scripts/verify-quick.sh + +# Create an annotated version tag carrying auto-generated release notes (git-cliff) +# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the +# image and publishes a Gitea Release whose body is this tag's message. Notes come +# entirely from Conventional Commits — no hand-written message required. +# +# git-cliff's --latest needs the tag to exist to scope the notes, so we create a +# throwaway lightweight tag, generate the notes, replace it with an annotated tag, +# then push. --cleanup=verbatim keeps the markdown "###" group headers (git's +# default cleanup would strip lines starting with "#"). +# +# Requires git-cliff: https://git-cliff.org/install +# Usage: make release VERSION=v0.3.0 +release: + @test -n "$(VERSION)" || { echo "Usage: make release VERSION=v0.3.0"; exit 1; } + @command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; } + @if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi + @echo "Generating release notes for $(VERSION)..." + @git tag "$(VERSION)" HEAD && \ + (git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \ + { git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; } + @git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp + @git push origin "$(VERSION)" + @echo "Pushed $(VERSION) — Gitea Actions will build the image and publish the Release." diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..e23533c --- /dev/null +++ b/cliff.toml @@ -0,0 +1,37 @@ +# git-cliff configuration — generates the body of each Gitea Release from +# Conventional Commits accumulated since the previous tag. Invoked in CI by +# orhun/git-cliff-action with --latest so only the current tag's section is +# emitted (no full history, no header — the Gitea Release title is the tag). +# Docs: https://git-cliff.org/docs/configuration + +[changelog] +header = "" +body = """ +{% for group, commits in commits | group_by(attribute="group") %}\ +### {{ group | upper_first }} +{% for commit in commits %}\ +- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }}) +{% endfor %}\ +{% endfor %}\ +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = false +require_conventional = false +split_commits = false +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^docs", group = "Documentation" }, + { message = "^test", group = "Tests" }, + { message = "^chore|^ci", group = "Miscellaneous Tasks" }, + { message = ".*", group = "Other" }, +] +filter_commits = false +tag_pattern = "v[0-9].*" +sort_commits = "oldest" diff --git a/release b/release new file mode 100755 index 0000000..3aac254 --- /dev/null +++ b/release @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +# Project-attached wrapper around `make release` so you can run: +# ./release v0.3.0 (or) ./release 0.3.0 +# instead of: +# make release VERSION=v0.3.0 +# Lives in the repo (no machine-specific alias needed). +set -eu + +[ "$#" -ge 1 ] || { echo "Usage: ./release v0.3.0" >&2; exit 1; } + +# Accept "0.3.0" or "v0.3.0"; ensure the tag starts with 'v' (the workflow +# only triggers on v* tags). +VERSION="v${1#v}" + +exec make release "VERSION=${VERSION}" From fffe0b17e635ccb3ff6d2eb1ba0d7c62d74afe61 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 15:50:02 -0400 Subject: [PATCH 25/52] ci(release): name Actions runs 'Release ' instead of the commit message Add a top-level run-name so the Gitea Actions runs list shows 'Release v0.3.0' rather than the tagged commit's subject. Uses the same expression (inputs.tag || ref_name) as the TAG env, so it resolves for both tag pushes and manual workflow_dispatch. --- .gitea/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 46f7699..c11c520 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,5 +1,9 @@ name: Release +# Overrides the default run name (the tagged commit's message) so the Actions +# runs list shows "Release v0.3.0" instead. +run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}" + # Publishes the Bookhoard container image to the Gitea container registry AND # creates a Gitea Release whose body is the annotated tag's message (generated # locally by `make release VERSION=...` via git-cliff). Triggered by a version From 59d5de3607825002202d2d3eeac13967813f59ee Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 31 Jul 2026 10:45:41 -0400 Subject: [PATCH 26/52] fix(scanner): eliminate fsnotify watcher leak and harden worker against panics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bookhoard container crashed with 'panic: Failed to create file watcher: too many open files' (media_scanner.go) after running for a few hours, preceded by floods of 'no space left on device' from watcher.Add. Root cause: every scan job called NewMediaScanner(), which eagerly created an fsnotify watcher. SetFolders() then walked the entire library tree and registered one inotify watch per directory (~3,000+ across the libraries), and ScanFolders() registered them again during its walk. The worker never called scanner.Close() on these ephemeral per-job scanners, and the worker loop had no recover(), so: 1. Leaked watchers accumulated until the kernel inotify watch cap was hit (ENOSPC -> 'no space left on device'), then 2. the process fd limit (ulimit -n 1024) was exhausted, causing fsnotify.NewWatcher() to fail with EMFILE, and 3. NewMediaScanner panicked on that error, taking down the whole process (exit code 2). With no restart policy the container stayed down. The scan jobs run frequently (scan_poll_interval), so the leak built up within hours. Note this was NOT a disk-space issue; df showed plenty free. Fix: - media_scanner.go: NewMediaScanner no longer creates a watcher eagerly (s.watcher starts nil), which removes the panic site entirely -- there is nothing to fail at construction. The watcher is created lazily only when needed. - media_scanner.go: SetFolders gains a [?1049h(B[?7h[?25lEvery 2.0s: boolgaruda-ser8: Fri 31 Jul 2026 10:45:41 AM EDTin 0.002s (127) sh: line 1: bool: command not found [?12l[?25h[?1049l [?1l> parameter. It creates and populates a watcher (returning an error instead of panicking) only when watch=true; otherwise it skips all watcher.Add calls. ScanFolders guards its watcher.Add with a nil check, and the WatchChanges event loop exits cleanly when there is no watcher (polling still runs). - worker.go: the worker() loop now wraps each job in defer/recover() so a panicking job is recorded as failed and can never kill the process. - worker.go: the three ephemeral scan handlers (processScanJob, processSetFoldersJob, processDirectoryScanJob) now defer scanner.Close() and call SetFolders(..., false), so scan jobs allocate zero watchers and zero inotify watches. Any pre-existing leak is also bounded by Close(). - handlers/scanner.go: the long-lived watch-mode scanners (StartScanner and StartWatchModeForLibrary) pass watch=true since they actually read watcher.Events for live change detection. - calibre_integration_test.go: updated to the new SetFolders signature (watch=false, matching one-off scan usage). Auto-add is fully preserved: new files are still detected by the periodic poller (startBackupScan), which is independent of fsnotify and unaffected by these changes. The watch-mode event loop remains as bonus responsiveness when inotify is available; through Docker bind mounts where inotify is unreliable, polling is what catches new books. --- cmd/server/tests/calibre_integration_test.go | 4 +- internal/handlers/scanner.go | 6 +- internal/services/media_scanner.go | 108 ++++++++++++------- internal/services/worker.go | 27 ++++- 4 files changed, 95 insertions(+), 50 deletions(-) diff --git a/cmd/server/tests/calibre_integration_test.go b/cmd/server/tests/calibre_integration_test.go index 9c06ccd..7107948 100644 --- a/cmd/server/tests/calibre_integration_test.go +++ b/cmd/server/tests/calibre_integration_test.go @@ -90,7 +90,7 @@ func TestCalibreLibraryScan(t *testing.T) { // Create scanner and configure it scanner := services.NewMediaScanner(setup.DB) scanner.SetAdminID(adminID) - err = scanner.SetFolders([]string{tmpDir}) + err = scanner.SetFolders([]string{tmpDir}, false) require.NoError(t, err, "Failed to set scanner folders") // Scan library @@ -167,7 +167,7 @@ func TestCalibreLibraryScanWithoutSidecar(t *testing.T) { // Create scanner and configure it scanner := services.NewMediaScanner(setup.DB) scanner.SetAdminID(adminID) - err = scanner.SetFolders([]string{tmpDir}) + err = scanner.SetFolders([]string{tmpDir}, false) require.NoError(t, err, "Failed to set scanner folders") // Scan library diff --git a/internal/handlers/scanner.go b/internal/handlers/scanner.go index 70e6a74..684269b 100644 --- a/internal/handlers/scanner.go +++ b/internal/handlers/scanner.go @@ -128,8 +128,8 @@ func (h *Handler) StartScanner(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } - // Set the folder paths - if err := h.scanner.SetFolders(req.FolderPaths); err != nil { + // Set the folder paths (watch=true: this long-lived scanner reads events) + if err := h.scanner.SetFolders(req.FolderPaths, true); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()}) } @@ -202,7 +202,7 @@ func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype } scanner := services.NewMediaScanner(h.db) - if err := scanner.SetFolders(folderPaths); err != nil { + if err := scanner.SetFolders(folderPaths, true); err != nil { return fmt.Errorf("failed to set scanner folders: %v", err) } diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 8f584c6..0a4d07c 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -146,16 +146,18 @@ type CalibreOPFMetadata struct { Timestamp *time.Time } -// NewMediaScanner creates a new media scanner instance +// NewMediaScanner creates a new media scanner instance. +// +// The fsnotify watcher is NOT created here. It is created lazily inside +// SetFolders only when watch=true (the long-lived watch-mode scanner). +// Ephemeral one-off scan jobs pass watch=false, so they never allocate a +// watcher (and thus can never panic on EMFILE/ENOSPC). This fixes the +// fd/inotify-watch leak where every scan job created a watcher that was +// never closed. func NewMediaScanner(db *database.Queries) *MediaScanner { - watcher, err := fsnotify.NewWatcher() - if err != nil { - panic(fmt.Sprintf("Failed to create file watcher: %v", err)) - } - return &MediaScanner{ db: db, - watcher: watcher, + watcher: nil, settingsCache: NewSettingsCache(30 * time.Second), dirtyDirs: make(map[string]time.Time), fileStability: make(map[string]*atomic.Bool), @@ -238,24 +240,34 @@ func (s *MediaScanner) GetStats() (int, int, int) { return s.totalFiles, s.newItems, s.errors } -func (s *MediaScanner) SetFolders(folders []string) error { +// SetFolders configures the scanner's folders and (optionally) sets up an +// fsnotify watcher over the full directory tree. +// +// watch should be true only for the single long-lived watch-mode scanner that +// actually consumes watcher.Events. Ephemeral scan jobs must pass false so no +// watcher (and thus no fd/inotify watches) is allocated — the watcher is never +// read by scan jobs and previously leaked one watcher per job. +func (s *MediaScanner) SetFolders(folders []string, watch bool) error { s.folders = folders - // Remove old watch if exists + // Always close any previously-owned watcher so reconfiguration doesn't leak. if s.watcher != nil { - if s.watcher != nil { - if err := s.watcher.Close(); err != nil { - fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err) - } + if err := s.watcher.Close(); err != nil { + fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err) } + s.watcher = nil } - // Create new watcher - watcher, err := fsnotify.NewWatcher() - if err != nil { - return fmt.Errorf("failed to create watcher: %v", err) + // Create + populate a fresh watcher only when the caller intends to read events. + if watch { + watcher, err := fsnotify.NewWatcher() + if err != nil { + // Return an error instead of panicking so a failed watcher can't + // take down the whole process. + return fmt.Errorf("failed to create watcher: %w", err) + } + s.watcher = watcher } - s.watcher = watcher // Build cache of allowed extensions per folder // Uses Go AllowedExtensions map as source of truth (not DB) @@ -286,31 +298,36 @@ func (s *MediaScanner) SetFolders(folders []string) error { } } - // Add all folders and their subdirectories to the watcher (like Audiobookshelf) - watchCount := 0 - for _, folder := range folders { - if err := s.watcher.Add(folder); err != nil { - fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err) - } else { - watchCount++ - } - filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if !d.IsDir() || path == folder { - return nil - } - if err := s.watcher.Add(path); err != nil { - fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err) + // Add all folders and their subdirectories to the watcher (like Audiobookshelf). + // Only when watching; scan jobs (watch=false) skip this entirely. + if s.watcher != nil { + watchCount := 0 + for _, folder := range folders { + if err := s.watcher.Add(folder); err != nil { + fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err) } else { watchCount++ } - return nil - }) - } + filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() || path == folder { + return nil + } + if err := s.watcher.Add(path); err != nil { + fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err) + } else { + watchCount++ + } + return nil + }) + } - fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders)) + fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders)) + } else { + fmt.Printf("[SCANNER] Configured %d root folders (watch mode disabled, no inotify watcher)\n", len(folders)) + } return nil } @@ -417,8 +434,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error { } if d.IsDir() { - if err := s.watcher.Add(path); err != nil { - fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err) + if s.watcher != nil { + if err := s.watcher.Add(path); err != nil { + fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err) + } } return nil } @@ -2601,6 +2620,13 @@ func (s *MediaScanner) WatchChanges(ctx context.Context) error { go s.startBackupScan(ctx) go func() { + // The event loop only runs if a real watcher was set up (watch=true). + // If watching with no watcher (e.g. inotify unavailable through a Docker + // bind mount), polling via startBackupScan above still handles detection. + if s.watcher == nil { + fmt.Printf("[WATCHER] No inotify watcher configured; relying on periodic polling for change detection\n") + return + } fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders)) for { select { diff --git a/internal/services/worker.go b/internal/services/worker.go index da7c0d4..8f509d2 100644 --- a/internal/services/worker.go +++ b/internal/services/worker.go @@ -205,7 +205,23 @@ func (w *Worker) worker() { return } - w.processJob(job) + // Recover from any panic inside a job so a single failing job can + // never crash the whole worker goroutine (and thus the process). + func() { + defer func() { + if r := recover(); r != nil { + fmt.Printf("[WORKER] panic in job %s (%s): %v\n", job.ID, job.Type, r) + w.mu.Lock() + w.results[job.ID] = &JobResult{ + JobID: job.ID, + Status: JobStatusFailed, + Error: fmt.Sprintf("panic: %v", r), + } + w.mu.Unlock() + } + }() + w.processJob(job) + }() case <-w.ctx.Done(): return @@ -348,6 +364,7 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) { } scanner := NewMediaScanner(db) + defer scanner.Close() scanner.job = job job.ProgressCallback = func(progress float64, filesScanned, newItems, errors int) { @@ -377,7 +394,7 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) { } } - if err := scanner.SetFolders(folders); err != nil { + if err := scanner.SetFolders(folders, false); err != nil { return nil, err } @@ -521,7 +538,8 @@ func (w *Worker) processSetFoldersJob(job *Job) (interface{}, error) { // Create scanner and configure folders scanner := NewMediaScanner(db) - if err := scanner.SetFolders(folders); err != nil { + defer scanner.Close() + if err := scanner.SetFolders(folders, false); err != nil { return nil, fmt.Errorf("failed to set folders: %w", err) } @@ -900,6 +918,7 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) { // Create temporary scanner instance for this job scanner := NewMediaScanner(db) + defer scanner.Close() scanner.job = job // Find which library owns this directory (prefix match for subdirectories) ctx := context.Background() @@ -918,7 +937,7 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) { folderPaths = append(folderPaths, f.FolderPath) } // Configure scanner with folders - if err := scanner.SetFolders(folderPaths); err != nil { + if err := scanner.SetFolders(folderPaths, false); err != nil { return nil, fmt.Errorf("failed to set folders: %w", err) } // Now scan the directory From d0040fe428df332cbad0ed276b1c23389252544a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 31 Jul 2026 10:45:53 -0400 Subject: [PATCH 27/52] chore(deploy): add restart: unless-stopped to app container The app service in docker-compose.yml had no restart policy (defaults to 'no'), so if the process exited -- e.g. the watcher-leak panic fixed in the previous commit -- the container stayed down until a manual restart. Adding restart: unless-stopped makes the container self-recover from crashes or host reboots, while still honoring explicit 'docker compose down'. Defense-in-depth alongside the scanner leak/panic fix: even if a future unforeseen panic occurs, the app comes back automatically. --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 099eb17..987f90f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ services: app: image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest} container_name: bookhoard + restart: unless-stopped environment: # Database Configuration DATABASE_HOST: db From 11617c186052fa3240780dae2129c569ddb933fe Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 5 Aug 2026 15:31:40 -0400 Subject: [PATCH 28/52] fix(reader): tighten mobile header/footer and add safe-area margins The reader chrome used the same dimensions at every screen size, and the book viewport offset was hardcoded to 52px. This made the header/footer oversized on phones and left the body text flush against (or overlapping) the bars, with no handling for notched-device safe areas. - Add viewport-fit=cover so notched devices expose safe-area insets. - Shrink the top bar on mobile (px-3 py-2 / text-base, scaling up at sm:) and hide the chapter title on phones (hidden sm:block sm:truncate). - Shrink the bottom bar on mobile (tighter padding/gap, p-1.5 sm:p-2 on buttons) while keeping all controls visible. - Replace the hardcoded top-[52px] bottom-[52px] viewport offsets with responsive calc() values (44/48px mobile, 60px at sm:) that fold in env(safe-area-inset-*), plus matching safe-area padding on the bars, so the book content always clears the chrome with a visible margin. Regenerates reader_templ.go and rebuilds style.css. --- templates/reader.templ | 36 ++++++++++++++++++------------------ templates/reader_templ.go | 14 +++++++------- web/static/style.css | 2 +- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/templates/reader.templ b/templates/reader.templ index b94d04f..9a87196 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -36,7 +36,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm - + { metadata.Title } - Bookhoard Reader @@ -71,7 +71,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm

-
+
@DictionaryPopup() @@ -82,25 +82,25 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) {
-
-
- +
+
+ ← Back -

{ metadata.Title }

+

{ metadata.Title }

-
-
+
+
-