H3 census bugs #2 and #3: both web PUTs used plain queries that bypassed the sync-aware layer entirely — no LWW, no source bookkeeping, and locator fields the client couldn't even send. The highlight PUT was the only one already routed (via HighlightID); the other two now mirror it: - SaveBookmarkRequest gains BookmarkID, SaveNoteRequest gains NoteID: id-targeted resolution reads the exact row, verifies ownership, LWWs under its stored dedup key, and resurrects tombstones when the edit is newer (same doctrine and comment structure as the highlight path). - Fields the PUT omits are coalesced from the stored row before the full-column sync update — the legacy web PUT sends only title/notes/position, and zeroing cfi/percentage/page on every edit would have been data loss. Verified both directions by TestH3_BookmarkPUTPreservesLocators (omitted survives, explicit lands). - The bookmark PUT's wire DTO gains the create endpoint's locator keys as optional fields (documented additive request keys in contract_pins.md); the note PUT keeps its content+position wire. UpdateMediaBookmark/UpdateMediaNote plain queries are now uncalled (the generated queries stay until the H4 query sweep).
1159 lines
39 KiB
Go
1159 lines
39 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
|
|
// HighlightID, when valid, targets that exact row (web PUTs edit by
|
|
// id): the save LWWs against it directly under its stored dedup key.
|
|
// The computed key depends on fields that legitimately change — the
|
|
// stored CFI drifts range→point shape after device echoes, and the
|
|
// user can edit the selection text — so a key-based upsert would mint
|
|
// a duplicate beside the very row being edited.
|
|
HighlightID pgtype.UUID
|
|
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
|
|
|
|
// Web edits arrive with the row id from the URL: resolve by id first
|
|
// and LWW against that row under its stored key. Content-derived keys
|
|
// are for lookups by identity (device pushes carry no row ids); an
|
|
// edit must never re-derive identity from (possibly edited) content.
|
|
if req.HighlightID.Valid {
|
|
byID, idErr := s.db.GetMediaHighlight(ctx, req.HighlightID)
|
|
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("query highlight by id: %w", idErr)
|
|
}
|
|
if idErr == nil {
|
|
if byID.UserID != req.UserID || byID.MediaItemID != req.MediaItemID {
|
|
return nil, fmt.Errorf("highlight %s belongs to another user or media item", req.HighlightID)
|
|
}
|
|
if dedupKey == "" {
|
|
dedupKey = byID.DedupKey.String
|
|
}
|
|
if byID.Deleted.Bool {
|
|
if !incomingNewerThanTombstone(req.ModifiedAt, byID.DeletedAt, byID.LastModifiedAt) {
|
|
return &SaveHighlightResult{Highlight: byID, 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, byID, dedupKey)
|
|
}
|
|
// No row with that id: fall through to identity-based resolution.
|
|
}
|
|
|
|
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)
|
|
|
|
// Web edits carry no device locators (the web reader never had a CRE
|
|
// xpointer) and may carry no CFI either: keep the stored ones so
|
|
// device-native serve-back and round-trip identity survive a web-side
|
|
// note/color edit instead of being wiped to empty.
|
|
startPosition := req.StartPosition
|
|
if startPosition == "" {
|
|
startPosition = existing.StartPosition.String
|
|
}
|
|
endPosition := req.EndPosition
|
|
if endPosition == "" {
|
|
endPosition = existing.EndPosition.String
|
|
}
|
|
epubcfiStart := req.EpubcfiStart
|
|
if epubcfiStart == "" {
|
|
epubcfiStart = existing.EpubcfiStart.String
|
|
}
|
|
epubcfiEnd := req.EpubcfiEnd
|
|
if epubcfiEnd == "" {
|
|
epubcfiEnd = existing.EpubcfiEnd.String
|
|
}
|
|
|
|
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
|
ID: existing.ID,
|
|
SelectionText: req.SelectionText,
|
|
StartPosition: pgText(startPosition),
|
|
EndPosition: pgText(endPosition),
|
|
Color: pgText(req.Color),
|
|
NoteText: pgText(req.NoteText),
|
|
PercentageStart: pgFloat8(req.PercentageStart),
|
|
PercentageEnd: pgFloat8(req.PercentageEnd),
|
|
EpubcfiStart: pgText(epubcfiStart),
|
|
EpubcfiEnd: pgText(epubcfiEnd),
|
|
ChapterReference: pgInt4(req.ChapterReference),
|
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
|
DeviceSyncData: deviceData,
|
|
UserID: req.UserID,
|
|
})
|
|
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) &&
|
|
locatorRefresher(req.EpubcfiStart, existing.EpubcfiStart) &&
|
|
locatorRefresher(req.EpubcfiEnd, existing.EpubcfiEnd)
|
|
return !contentSame, !contentSame
|
|
}
|
|
|
|
existingMod := existing.LastModifiedAt
|
|
if !existingMod.Valid {
|
|
existingMod = existing.UpdatedAt
|
|
}
|
|
return req.ModifiedAt.After(existingMod.Time), true
|
|
}
|
|
|
|
// locatorRefresher reports whether an incoming locator leaves the stored
|
|
// one untouched: either the push carried none (empty coalesces to the
|
|
// stored value in applyLWW) or it matches what is stored. A non-empty
|
|
// incoming locator that DIFFERS is a deliberate refresh: device echoes
|
|
// re-derive their canonical CFIs on every push, and an improvement (a
|
|
// converter fix re-landing a corrupted anchor, drift repair) must reach
|
|
// the row even when the annotation content is otherwise identical —
|
|
// without this, content-equal echoes resolve to "skip" and a bad stored
|
|
// locator can never heal.
|
|
func locatorRefresher(incoming string, existing pgtype.Text) bool {
|
|
if incoming == "" {
|
|
return true
|
|
}
|
|
return textEq(incoming, existing)
|
|
}
|
|
|
|
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,
|
|
userID, 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)
|
|
}
|
|
// Ownership predicate: the caller must own the row. A mismatched user
|
|
// gets a not-found-style error, never the row's existence.
|
|
if h.UserID != userID {
|
|
return fmt.Errorf("tombstone highlight by ID: %w", pgx.ErrNoRows)
|
|
}
|
|
err = s.db.TombstoneMediaHighlightByID(ctx, database.TombstoneMediaHighlightByIDParams{
|
|
ID: highlightID,
|
|
UserID: userID,
|
|
})
|
|
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
|
|
// NoteID, when valid, targets that exact row (web PUTs edit by id):
|
|
// the save LWWs against it under its stored dedup key, mirroring
|
|
// SaveHighlightRequest.HighlightID.
|
|
NoteID pgtype.UUID
|
|
}
|
|
|
|
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
|
|
|
|
// Web edits arrive with the row id from the URL: resolve by id and LWW
|
|
// against that row under its stored key (same doctrine as
|
|
// SaveHighlight's HighlightID path). Locator columns the note PUT
|
|
// does not carry are coalesced from the stored row so the full-column
|
|
// sync update cannot zero them.
|
|
if req.NoteID.Valid {
|
|
byID, idErr := s.db.GetMediaNote(ctx, req.NoteID)
|
|
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("query note by id: %w", idErr)
|
|
}
|
|
if idErr == nil {
|
|
if byID.UserID != req.UserID || byID.MediaItemID != req.MediaItemID {
|
|
return nil, fmt.Errorf("note %s belongs to another user or media item", req.NoteID)
|
|
}
|
|
if dedupKey == "" {
|
|
dedupKey = byID.DedupKey.String
|
|
}
|
|
if req.EpubcfiLocation == "" {
|
|
req.EpubcfiLocation = byID.EpubcfiLocation.String
|
|
}
|
|
if req.PercentageLocation == 0 {
|
|
req.PercentageLocation = byID.PercentageLocation.Float64
|
|
}
|
|
if req.CharacterStart == 0 {
|
|
req.CharacterStart = byID.CharacterStart.Int32
|
|
}
|
|
if req.CharacterEnd == 0 {
|
|
req.CharacterEnd = byID.CharacterEnd.Int32
|
|
}
|
|
if req.ChapterReference == 0 {
|
|
req.ChapterReference = byID.ChapterReference.Int32
|
|
}
|
|
if req.ParagraphReference == 0 {
|
|
req.ParagraphReference = byID.ParagraphReference.Int32
|
|
}
|
|
if byID.Deleted.Bool {
|
|
if !incomingNewerThanTombstone(req.ModifiedAt, byID.DeletedAt, byID.LastModifiedAt) {
|
|
return &SaveNoteResult{Note: byID, Outcome: SaveOutcomeDeleted}, nil
|
|
}
|
|
// Newer than the tombstone: a deliberate re-create. Resurrect
|
|
// via the LWW update (which clears deleted/deleted_at).
|
|
}
|
|
return s.applyNoteLWW(ctx, req, byID, dedupKey)
|
|
}
|
|
// No row with that id: fall through to identity-based resolution.
|
|
}
|
|
|
|
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,
|
|
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),
|
|
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, userID pgtype.UUID) error {
|
|
return s.db.TombstoneMediaNoteByID(ctx, database.TombstoneMediaNoteByIDParams{
|
|
ID: id,
|
|
UserID: userID,
|
|
})
|
|
}
|
|
|
|
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
|
|
// OriginSource records the client that created the bookmark (set once
|
|
// at insert; unlike Source it is not updated by later writers).
|
|
OriginSource string
|
|
// DedupKey overrides the computed key for device echoes (see
|
|
// SaveHighlightRequest).
|
|
DedupKey string
|
|
// BookmarkID, when valid, targets that exact row (web PUTs edit by
|
|
// id): the save LWWs against it under its stored dedup key, mirroring
|
|
// SaveHighlightRequest.HighlightID — an edit must not re-derive
|
|
// identity from (possibly edited) content.
|
|
BookmarkID pgtype.UUID
|
|
}
|
|
|
|
type SaveBookmarkResult struct {
|
|
Bookmark database.MediaBookmarks
|
|
Outcome SaveOutcome
|
|
Conflict bool
|
|
}
|
|
|
|
func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) {
|
|
dedupKey := req.DedupKey
|
|
|
|
// Web edits arrive with the row id from the URL: resolve by id and LWW
|
|
// against that row under its stored key (same doctrine as
|
|
// SaveHighlight's HighlightID path). Fields the edit does not carry
|
|
// are coalesced from the stored row — the web PUT predates the
|
|
// locator columns and omits them; zeroing them on update would be
|
|
// data loss.
|
|
if req.BookmarkID.Valid {
|
|
byID, idErr := s.db.GetMediaBookmark(ctx, req.BookmarkID)
|
|
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("query bookmark by id: %w", idErr)
|
|
}
|
|
if idErr == nil {
|
|
if byID.UserID != req.UserID || byID.MediaItemID != req.MediaItemID {
|
|
return nil, fmt.Errorf("bookmark %s belongs to another user or media item", req.BookmarkID)
|
|
}
|
|
if dedupKey == "" {
|
|
dedupKey = byID.DedupKey.String
|
|
}
|
|
if req.CFIPosition == "" {
|
|
req.CFIPosition = byID.CfiPosition.String
|
|
}
|
|
if req.EpubcfiLocation == "" {
|
|
req.EpubcfiLocation = byID.EpubcfiLocation.String
|
|
}
|
|
if req.PercentageLoc == 0 {
|
|
req.PercentageLoc = byID.PercentageLocation.Float64
|
|
}
|
|
if req.PageNumber == 0 {
|
|
req.PageNumber = byID.PageNumber.Int32
|
|
}
|
|
if req.ChapterNumber == 0 {
|
|
req.ChapterNumber = byID.ChapterNumber.Int32
|
|
}
|
|
if req.ChapterReference == 0 {
|
|
req.ChapterReference = byID.ChapterReference.Int32
|
|
}
|
|
if byID.Deleted.Bool {
|
|
if !incomingNewerThanTombstone(req.ModifiedAt, byID.DeletedAt, byID.LastModifiedAt) {
|
|
return &SaveBookmarkResult{Bookmark: byID, Outcome: SaveOutcomeDeleted}, nil
|
|
}
|
|
// Newer than the tombstone: a deliberate re-create. Resurrect
|
|
// via the LWW update (which clears deleted/deleted_at).
|
|
}
|
|
return s.applyBookmarkLWW(ctx, req, byID, dedupKey)
|
|
}
|
|
// No row with that id: fall through to identity-based resolution.
|
|
}
|
|
|
|
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 at the same
|
|
// location. Resurrect via the LWW update so the row keeps its id
|
|
// and origin.
|
|
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,
|
|
OriginSource: pgText(req.OriginSource),
|
|
})
|
|
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)
|
|
|
|
// Web saves carry no device locators: keep the stored ones so a web
|
|
// title/note edit never wipes the device-native position (mirrors the
|
|
// highlight path's coalescing).
|
|
cfiPosition := req.CFIPosition
|
|
if cfiPosition == "" {
|
|
cfiPosition = existing.CfiPosition.String
|
|
}
|
|
position := req.Position
|
|
if position == "" {
|
|
position = existing.Position.String
|
|
}
|
|
|
|
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
|
ID: existing.ID,
|
|
PageNumber: pgInt4(req.PageNumber),
|
|
ChapterNumber: pgInt4(req.ChapterNumber),
|
|
CfiPosition: pgText(cfiPosition),
|
|
Title: req.Title,
|
|
Position: pgText(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,
|
|
UserID: req.UserID,
|
|
})
|
|
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) &&
|
|
locatorRefresher(req.CFIPosition, existing.CfiPosition) &&
|
|
locatorRefresher(req.Position, existing.Position)
|
|
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, userID, 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)
|
|
}
|
|
// Ownership predicate: the caller must own the row.
|
|
if bm.UserID != userID {
|
|
return fmt.Errorf("tombstone bookmark by ID: %w", pgx.ErrNoRows)
|
|
}
|
|
err = s.db.TombstoneMediaBookmarkByID(ctx, database.TombstoneMediaBookmarkByIDParams{
|
|
ID: bookmarkID,
|
|
UserID: userID,
|
|
})
|
|
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
|
|
}
|