diff --git a/database/schema/schema.sql b/database/schema/schema.sql index aca6ae7..f1e53b0 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -1344,8 +1344,10 @@ CREATE TABLE IF NOT EXISTS media_bookmarks ( title VARCHAR(255) NOT NULL, position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency notes TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(media_item_id, user_id, title) + created_at TIMESTAMPTZ DEFAULT NOW() + -- No UNIQUE(media_item_id, user_id, title): bookmarks are identified by + -- their location (dedup_key), titles are display labels shared verbatim + -- across clients (KOReader auto-labels repeat within a chapter). ); CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id); @@ -1386,6 +1388,14 @@ 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; +-- Provenance: the client that CREATED the bookmark (unlike +-- last_modified_source, which tracks the last writer). Set once at insert. +ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS origin_source VARCHAR(30); +-- Identity is the dedup_key (location), not the title; drop the legacy +-- unique-title constraint so same-title bookmarks on different pages can +-- coexist (re-creating over a tombstone with a changed position also +-- relied on this). Catalog-only change, safe to re-run. +ALTER TABLE media_bookmarks DROP CONSTRAINT IF EXISTS media_bookmarks_media_item_id_user_id_title_key; CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup ON media_highlights (user_id, media_item_id, dedup_key) diff --git a/internal/database/models.go b/internal/database/models.go index 1d937f6..5355e97 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -185,6 +185,7 @@ type MediaBookmarks struct { 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"` + OriginSource pgtype.Text `db:"origin_source" json:"origin_source"` } type MediaHighlights struct { diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 1541229..c16f6c7 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -624,7 +624,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, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_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, origin_source ` type CreateMediaBookmarkParams struct { @@ -670,6 +670,7 @@ func (q *Queries) CreateMediaBookmark(ctx context.Context, arg CreateMediaBookma &i.ChapterReference, &i.Deleted, &i.DeletedAt, + &i.OriginSource, ) return i, err } @@ -680,10 +681,10 @@ INSERT INTO media_bookmarks ( cfi_position, title, position, notes, percentage_location, epubcfi_location, chapter_reference, dedup_key, last_modified_at, last_modified_source, - device_sync_data + device_sync_data, origin_source ) 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 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 +) 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, origin_source ` type CreateMediaBookmarkFullParams struct { @@ -702,6 +703,7 @@ type CreateMediaBookmarkFullParams struct { 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"` + OriginSource pgtype.Text `db:"origin_source" json:"origin_source"` } func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error) { @@ -721,6 +723,7 @@ func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBo arg.LastModifiedAt, arg.LastModifiedSource, arg.DeviceSyncData, + arg.OriginSource, ) var i MediaBookmarks err := row.Scan( @@ -743,6 +746,7 @@ func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBo &i.ChapterReference, &i.Deleted, &i.DeletedAt, + &i.OriginSource, ) return i, err } @@ -4524,7 +4528,7 @@ func (q *Queries) GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLi } 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 +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, origin_source FROM media_bookmarks WHERE id = $1 ` func (q *Queries) GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error) { @@ -4550,13 +4554,14 @@ func (q *Queries) GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBo &i.ChapterReference, &i.Deleted, &i.DeletedAt, + &i.OriginSource, ) 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 +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, origin_source 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 @@ -4594,12 +4599,13 @@ func (q *Queries) GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBo &i.ChapterReference, &i.Deleted, &i.DeletedAt, + &i.OriginSource, ) 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, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks +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, origin_source FROM media_bookmarks WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC ` @@ -4638,6 +4644,7 @@ func (q *Queries) GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksPa &i.ChapterReference, &i.Deleted, &i.DeletedAt, + &i.OriginSource, ); err != nil { return nil, err } @@ -11513,7 +11520,7 @@ SET position = $4, last_modified_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, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_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, origin_source ` type UpdateMediaBookmarkParams struct { @@ -11553,6 +11560,7 @@ func (q *Queries) UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookma &i.ChapterReference, &i.Deleted, &i.DeletedAt, + &i.OriginSource, ) return i, err } @@ -11575,7 +11583,7 @@ UPDATE media_bookmarks SET deleted = FALSE, deleted_at = NULL 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 +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, origin_source ` type UpdateMediaBookmarkForSyncParams struct { @@ -11631,6 +11639,7 @@ func (q *Queries) UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMedi &i.ChapterReference, &i.Deleted, &i.DeletedAt, + &i.OriginSource, ) return i, err } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 8681a0b..4c33b93 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -898,9 +898,9 @@ INSERT INTO media_bookmarks ( cfi_position, title, position, notes, percentage_location, epubcfi_location, chapter_reference, dedup_key, last_modified_at, last_modified_source, - device_sync_data + device_sync_data, origin_source ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 ) RETURNING *; -- name: UpdateMediaBookmarkForSync :one diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index 2ea2a2c..30af02b 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -741,6 +741,7 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, Position: position, ChapterNumber: int32(bookmark.Chapter), Source: "koreader", + OriginSource: "koreader", DeviceSyncData: deviceData, DedupKey: dedupKey, }) diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 3642ecf..e1924fa 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -152,6 +152,9 @@ type CreateMediaBookmarkRequest struct { ChapterNumber int32 `json:"chapter_number"` Percentage float64 `json:"percentage"` ChapterReference int32 `json:"chapter_reference"` + // Origin labels the creating client for display ("android", "web"). + // Optional: defaults to "web" for browser callers. + Origin string `json:"origin" validate:"omitempty,max=30"` } // UpdateMediaBookmarkRequest represents the request for updating a media bookmark @@ -1750,6 +1753,10 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error { // The sync-aware path (dedup + LWW + tombstones) is preferred; fall back // to the plain query when the service isn't wired (e.g. some tests). if mh.annotationSvc != nil { + origin := req.Origin + if origin == "" { + origin = "web" + } result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, @@ -1762,6 +1769,7 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error { PercentageLoc: req.Percentage, ChapterReference: req.ChapterReference, Source: "web", + OriginSource: origin, ModifiedAt: time.Now(), }) if err != nil { diff --git a/internal/sync/annotations.go b/internal/sync/annotations.go index 3746ff4..abae2fb 100644 --- a/internal/sync/annotations.go +++ b/internal/sync/annotations.go @@ -590,6 +590,9 @@ type SaveBookmarkRequest struct { Source string ModifiedAt time.Time DeviceSyncData json.RawMessage + // OriginSource records the client that created the bookmark (set once + // at insert; unlike Source it is not updated by later writers). + OriginSource string // DedupKey overrides the computed key for device echoes (see // SaveHighlightRequest). DedupKey string @@ -624,9 +627,9 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) { return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil } - // Newer than the tombstone: a deliberate re-create. Resurrect via the - // LWW update instead of INSERT (the tombstoned row still holds the - // UNIQUE(media_item_id, user_id, title) slot). + // Newer than the tombstone: a deliberate re-create at the same + // location. Resurrect via the LWW update so the row keeps its id + // and origin. return s.applyBookmarkLWW(ctx, req, existing, dedupKey) } @@ -656,6 +659,7 @@ func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmark LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, DeviceSyncData: deviceData, + OriginSource: pgText(req.OriginSource), }) if err != nil { return nil, fmt.Errorf("create bookmark: %w", err) diff --git a/templates/reader.templ b/templates/reader.templ index 60a04a2..39c46ca 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -730,10 +730,33 @@ templ ReaderAnnotationsDrawer() { href="#" @click.prevent="goToBookmark(bookmark)" class="flex-1 min-w-0 block py-2 hover:bg-gray-700 rounded px-2" + x-show="!bookmark.renameOpen" > +
+ +
+ + +
+
+