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" ) // TombstoneTTL is the fallback retention for soft-deleted annotations when no // settings registry is wired (e.g. in tests). It matches the historical value. const TombstoneTTL = 30 * 24 * time.Hour type AnnotationService struct { db *database.Queries connMgr *ConnectionManager settings *database.SettingsRegistry } func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService { return &AnnotationService{db: db, connMgr: connMgr} } // SetSettings wires the tunable settings registry. When wired, the tombstone // TTL is read live from the DB; otherwise the package const TombstoneTTL is // used. func (s *AnnotationService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg } // tombstoneTTL returns the active tombstone retention window. func (s *AnnotationService) tombstoneTTL() time.Duration { if s.settings != nil { return s.settings.TombstoneTTL() } return TombstoneTTL } // ActiveTombstoneTTL exposes the configured tombstone retention window for // callers outside the sync package (e.g. kobo/koreader handlers) that need to // compute cutoffs consistently with the service. func (s *AnnotationService) ActiveTombstoneTTL() time.Duration { return s.tombstoneTTL() } type SaveOutcome string const ( SaveOutcomeCreated SaveOutcome = "created" SaveOutcomeUpdated SaveOutcome = "updated" SaveOutcomeSkipped SaveOutcome = "skipped" SaveOutcomeDeleted SaveOutcome = "deleted" ) 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 // DedupKey overrides the computed key when the client echoes back an // annotation it received from us (device echoes carry device-native // locators, so the computed key would never match the original row and // every pull→push cycle would mint a duplicate). DedupKey string } type SaveHighlightResult struct { Highlight database.MediaHighlights Outcome SaveOutcome Conflict bool } func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) { dedupKey := req.DedupKey if dedupKey == "" { 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 !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) { return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil } // Newer than the tombstone: a deliberate re-create. Resurrect via the // LWW update (which clears deleted/deleted_at). return s.applyLWW(ctx, req, existing, 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 } // TombstoneBookmarkByDedupKey soft-deletes a bookmark by its dedup key — the // device-sync counterpart of TombstoneHighlight. Devices report deletions by // dedup key (they have no row IDs), so this keeps bookmark delete propagation // symmetric with highlights. func (s *AnnotationService) TombstoneBookmarkByDedupKey( ctx context.Context, userID, mediaItemID pgtype.UUID, dedupKey string, source string, ) error { if dedupKey == "" { return nil } err := s.db.TombstoneMediaBookmarkByDedupKey(ctx, database.TombstoneMediaBookmarkByDedupKeyParams{ UserID: userID, MediaItemID: mediaItemID, DedupKey: pgtype.Text{String: dedupKey, Valid: true}, }) if err != nil { return fmt.Errorf("tombstone bookmark: %w", err) } s.broadcast(pgtype.UUID{}, userID, mediaItemID, "bookmark_delete", source) return nil } // ValidAnnotationKind reports whether kind is one of the annotation types // accepted by the history restore/purge endpoints. func ValidAnnotationKind(kind string) bool { return kind == "highlight" || kind == "note" || kind == "bookmark" } // RestoreAnnotationByID clears the tombstone on a deleted annotation, // returning it to the active set. The row itself was never removed, so // restoration is lossless. Returns false when no matching deleted annotation // exists (wrong owner, wrong book, or not actually deleted). func (s *AnnotationService) RestoreAnnotationByID( ctx context.Context, kind string, userID, mediaItemID, annotationID pgtype.UUID, ) (bool, error) { var rows int64 var err error switch kind { case "highlight": rows, err = s.db.RestoreMediaHighlightByID(ctx, database.RestoreMediaHighlightByIDParams{ ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) case "note": rows, err = s.db.RestoreMediaNoteByID(ctx, database.RestoreMediaNoteByIDParams{ ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) case "bookmark": rows, err = s.db.RestoreMediaBookmarkByID(ctx, database.RestoreMediaBookmarkByIDParams{ ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) default: return false, fmt.Errorf("unknown annotation kind: %s", kind) } if err != nil { return false, fmt.Errorf("restore %s: %w", kind, err) } if rows > 0 { s.broadcast(annotationID, userID, mediaItemID, kind, "web") } return rows > 0, nil } // PurgeAnnotationByID permanently deletes an already-tombstoned annotation // from the history. Unlike a tombstone this is irreversible; the TTL-driven // maintenance sweep does the same thing to old tombstones eventually. func (s *AnnotationService) PurgeAnnotationByID( ctx context.Context, kind string, userID, mediaItemID, annotationID pgtype.UUID, ) (bool, error) { var rows int64 var err error switch kind { case "highlight": rows, err = s.db.PurgeMediaHighlightByID(ctx, database.PurgeMediaHighlightByIDParams{ ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) case "note": rows, err = s.db.PurgeMediaNoteByID(ctx, database.PurgeMediaNoteByIDParams{ ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) case "bookmark": rows, err = s.db.PurgeMediaBookmarkByID(ctx, database.PurgeMediaBookmarkByIDParams{ ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) default: return false, fmt.Errorf("unknown annotation kind: %s", kind) } if err != nil { return false, fmt.Errorf("purge %s: %w", kind, err) } return rows > 0, nil } func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error { cutoff := pgtype.Timestamptz{Time: time.Now().Add(-s.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 } // StartDailyMaintenance launches a single background goroutine that runs all // periodic cleanup tasks once every 24 hours: expired annotation tombstones, // expired/revoked refresh tokens (retention follows the configured session // duration), and expired OPDS tokens. Each task is independent; a failure in // one is logged and does not skip the others. The returned CancelFunc stops the // goroutine and the underlying ticker; it must be invoked on shutdown. func (s *AnnotationService) StartDailyMaintenance() 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: s.runDailyMaintenance(ctx) } } }() return cancel } // runDailyMaintenance executes every periodic cleanup task. Tasks run // sequentially under the single daily-tick goroutine so there is no added // concurrency. All three queries only delete rows that are already unusable // (expired or revoked), so this never logs out active sessions. func (s *AnnotationService) runDailyMaintenance(ctx context.Context) { if err := s.PurgeExpiredTombstones(ctx); err != nil { log.Printf("maintenance: tombstone purge failed: %v", err) } if err := s.db.CleanupExpiredOpdsTokens(ctx); err != nil { log.Printf("maintenance: OPDS token purge failed: %v", err) } // Refresh-token retention follows the configured session duration; re-read // on every tick so live settings changes are honored. Guarded so unwired // test paths simply skip cleanup (production always wires the registry). if s.settings != nil { retention := s.settings.SessionDuration().Seconds() if err := s.db.CleanupExpiredRefreshTokens(ctx, retention); err != nil { log.Printf("maintenance: refresh token purge failed: %v", err) } } } 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 DedupKey string // overrides the computed key for device echoes } 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 := req.DedupKey if dedupKey == "" { 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 { if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) { return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil } // Newer than the tombstone: a deliberate re-create. Resurrect. return s.applyNoteLWW(ctx, req, existing, dedupKey) } 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 // DedupKey overrides the computed key for device echoes (see // SaveHighlightRequest). DedupKey string } type SaveBookmarkResult struct { Bookmark database.MediaBookmarks Outcome SaveOutcome Conflict bool } func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) { dedupKey := req.DedupKey if dedupKey == "" { 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 !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). return s.applyBookmarkLWW(ctx, req, existing, 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)) } // incomingNewerThanTombstone reports whether an incoming save should // resurrect a tombstoned annotation. A save carrying a modification time // newer than the tombstone (e.g. the user deliberately re-adding on the web, // or a device that genuinely re-created it) wins; a save with a missing or // older timestamp is treated as a stale replay from a client that still has // the deleted annotation, and the tombstone stands. func incomingNewerThanTombstone(incoming time.Time, deletedAt, lastModifiedAt pgtype.Timestamptz) bool { if incoming.IsZero() { return false } tombstone := deletedAt.Time if lastModifiedAt.Valid && lastModifiedAt.Time.After(tombstone) { tombstone = lastModifiedAt.Time } return incoming.After(tombstone) } 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 }