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} + } +}