Files
bookhoard/internal/sync/annotations.go
john-okeefe 3b15766149 feat(sync): add AnnotationService with dedup, LWW, and tombstone management
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
2026-07-29 14:48:47 -04:00

755 lines
23 KiB
Go

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
}