RestoreAnnotationByID and PurgeAnnotationByID dispatch on annotation kind (highlight/note/bookmark) to the new queries, broadcasting an annotation update on restore so connected web sessions refresh. Both report whether a row actually changed. TombstoneBookmarkByDedupKey mirrors the existing TombstoneHighlight for bookmarks: devices report deletions by dedup key (they have no row IDs), and until now only highlights had a key-based tombstone path — device bookmark deletions had nowhere to land. ValidAnnotationKind centralizes the kind check the HTTP handlers share.
939 lines
31 KiB
Go
939 lines
31 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
|
|
// 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
|
|
}
|