The 30-day retention window for soft-deleted annotations was a package const; move it behind the registry so it can be tuned live. annotations.go: - AnnotationService gains an optional *database.SettingsRegistry and a tombstoneTTL() helper. The skip-resurrect checks and the purge cutoff now call it instead of reading the TombstoneTTL const directly. - Add ActiveTombstoneTTL() so callers outside the sync package can compute cutoffs consistently with the service. - The package-level TombstoneTTL const is retained as the fallback for tests / unwired code paths. kobo.go, koreader.go: - The per-book tombstone sweep cutoff now uses h.annotationSvc.ActiveTombstoneTTL() instead of the wsync.TombstoneTTL const, so both the service and the handlers honor the configured TTL.
776 lines
24 KiB
Go
776 lines
24 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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
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) < s.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(-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
|
|
}
|
|
|
|
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) < s.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
|
|
}
|