From c579179fee04bbae7242c2502db108ff29241117 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 17 Oct 2026 15:47:57 -0400 Subject: [PATCH] refactor(annotations): AnnotationService is a required constructor dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1's doctrine applied to the annotation service, which had been left on setters: NewMediaHandler/NewKoboHandler/NewKOReaderHandler/ NewSyncQueueProcessor(WithConfig) now take it as a parameter; the four SetAnnotationService setters are deleted; wiring updated (main.go, router/sync.go, test harness). With the service unskippable, the 18 nil-guards and their plain-query fallbacks die: - media.go: the create fallbacks for notes/highlights/bookmarks (the bookmark one was the H3 census's Bug #1 — it dropped percentage/chapter_reference the sync path carries), the highlight PUT fallback, and the three delete-path plain deletes (tombstone is now the only delete semantics). - kobo.go: both markup-ingest annotation loops (the bookmark fallback wrote the text into media_notes — a category error the fallback never should have had). - koreader.go: all three push-stack guards. - queue.go: the three "annotation service not available" errors in syncNote/syncHighlight/syncBookmark. Behavior note: the deleted highlight create/PUT fallbacks were the only paths passing note_id to the plain query — the sync service has no NoteID field, so the web's highlight→note link was already being dropped on the live path; this commit makes that pre-existing limitation explicit rather than papering it over (surfacing note_id on SaveHighlightRequest is left for a behavior commit that needs it). Pins (now 20 incl. the 3 annotation CRUD suites) + H1 tests + both unit suites green. --- cmd/server/main.go | 9 +- cmd/server/tests/test_helpers_test.go | 8 +- internal/handlers/kobo.go | 258 +++++++------------- internal/handlers/koreader.go | 200 +++++++--------- internal/handlers/media.go | 323 ++++++++------------------ internal/router/sync.go | 3 +- internal/sync/annotations.go | 18 +- internal/sync/queue.go | 33 +-- internal/sync/queue_test.go | 2 +- 9 files changed, 290 insertions(+), 564 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 1168c488..0930bd0f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -130,8 +130,7 @@ func main() { maintenanceCancel := annotationService.StartDailyMaintenance() defer maintenanceCancel() - queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, progressService, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize) - queueProcessor.SetAnnotationService(annotationService) + queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, progressService, annotationService, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize) // Create library service libraryService := services.NewLibraryService(queries) @@ -147,8 +146,7 @@ func main() { worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager) services.WorkerInstance = worker - koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor, progressService) - koreaderHandler.SetAnnotationService(annotationService) + koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor, progressService, annotationService) koreaderHandler.SetLibraryService(libraryService) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) conflictHandler := handlers.NewConflictHandler(queries, connManager, progressService) @@ -167,8 +165,7 @@ func main() { dashboardHandler := handlers.NewDashboardHandler(queries) seriesHandler := handlers.NewSeriesHandler(queries) filtersHandler := handlers.NewFiltersHandler(queries) - mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService) - mediaHandler.SetAnnotationService(annotationService) + mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService) matchingHandler := handlers.NewMatchingHandler(queries, connManager) jobsHandler := handlers.NewJobsHandler(queries, worker) diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index 7bee6a77..dfff6800 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -483,12 +483,11 @@ func setupTestServer(t *testing.T) *TestServerSetup { progressService := wsync.NewProgressService(queries, connManager) annotationService := wsync.NewAnnotationService(queries, connManager) - queueProcessor := wsync.NewSyncQueueProcessor(queries, progressService) + queueProcessor := wsync.NewSyncQueueProcessor(queries, progressService, annotationService) queueCtx, queueCancel := context.WithCancel(context.Background()) go queueProcessor.Start(queueCtx) - koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor, progressService) - koreaderHandler.SetAnnotationService(annotationService) + koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor, progressService, annotationService) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) conflictHandler := handlers.NewConflictHandler(queries, connManager, progressService) analyticsHandler := handlers.NewAnalyticsHandler(queries) @@ -508,8 +507,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { statisticsService := services.NewStatisticsService(queries) statsHandler := handlers.NewStatsHandler(statisticsService) seriesHandler := handlers.NewSeriesHandler(queries) - mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, worker) - mediaHandler.SetAnnotationService(annotationService) + mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService, worker) matchingHandler := handlers.NewMatchingHandler(queries, connManager) // Create conversion service for OPDS diff --git a/internal/handlers/kobo.go b/internal/handlers/kobo.go index 3ddb4477..43d38dd4 100644 --- a/internal/handlers/kobo.go +++ b/internal/handlers/kobo.go @@ -26,12 +26,8 @@ type KoboHandler struct { bookResolver *services.BookResolver } -func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager, progressSvc *wsync.ProgressService) *KoboHandler { - return &KoboHandler{db: db, connManager: connManager, progressSvc: progressSvc, bookResolver: services.NewBookResolver(db)} -} - -func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) { - h.annotationSvc = svc +func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService) *KoboHandler { + return &KoboHandler{db: db, connManager: connManager, progressSvc: progressSvc, annotationSvc: annotationSvc, bookResolver: services.NewBookResolver(db)} } func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) { @@ -475,65 +471,43 @@ func (h *KoboHandler) Markup(c *echo.Context) error { switch bookmarkSync.BookmarkType { case "annotation": if bookmarkSync.BookmarkText != "" { - if h.annotationSvc != nil { - deviceData, _ := json.Marshal(map[string]interface{}{ - "bookmark_id": bookmarkSync.BookmarkId, - "date_created": bookmarkSync.DateCreated, - }) + deviceData, _ := json.Marshal(map[string]interface{}{ + "bookmark_id": bookmarkSync.BookmarkId, + "date_created": bookmarkSync.DateCreated, + }) - result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - SelectionText: bookmarkSync.BookmarkText, - StartPosition: bookmarkSync.BookmarkId, - EndPosition: bookmarkSync.BookmarkId, - Color: "#ffff00", - NoteText: bookmarkSync.BookmarkTitle, - Source: "kobo", - DeviceSyncData: deviceData, - }) - if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { - bookmarksSynced++ - } - } else { - h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - SelectionText: bookmarkSync.BookmarkText, - StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - Color: pgtype.Text{String: "#ffff00", Valid: true}, - }) + result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + SelectionText: bookmarkSync.BookmarkText, + StartPosition: bookmarkSync.BookmarkId, + EndPosition: bookmarkSync.BookmarkId, + Color: "#ffff00", + NoteText: bookmarkSync.BookmarkTitle, + Source: "kobo", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { bookmarksSynced++ } } case "bookmark": if bookmarkSync.BookmarkText != "" { - if h.annotationSvc != nil { - deviceData, _ := json.Marshal(map[string]interface{}{ - "bookmark_id": bookmarkSync.BookmarkId, - "date_created": bookmarkSync.DateCreated, - }) + deviceData, _ := json.Marshal(map[string]interface{}{ + "bookmark_id": bookmarkSync.BookmarkId, + "date_created": bookmarkSync.DateCreated, + }) - result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Title: bookmarkSync.BookmarkText, - Position: bookmarkSync.BookmarkId, - ChapterNumber: int32(bookmarkSync.Chapter), - Source: "kobo", - DeviceSyncData: deviceData, - }) - if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { - bookmarksSynced++ - } - } else { - h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Content: bookmarkSync.BookmarkText, - Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - }) + result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + Title: bookmarkSync.BookmarkText, + Position: bookmarkSync.BookmarkId, + ChapterNumber: int32(bookmarkSync.Chapter), + Source: "kobo", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { bookmarksSynced++ } } @@ -600,7 +574,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error { BookmarksSynced: bookmarksSynced, } - if h.annotationSvc != nil && len(processedBooks) > 0 { + if len(processedBooks) > 0 { cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true} for mediaItemID, contentId := range processedBooks { tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{ @@ -668,65 +642,43 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error { switch bookmarkSync.BookmarkType { case "annotation": if bookmarkSync.BookmarkText != "" { - if h.annotationSvc != nil { - deviceData, _ := json.Marshal(map[string]interface{}{ - "bookmark_id": bookmarkSync.BookmarkId, - "date_created": bookmarkSync.DateCreated, - }) + deviceData, _ := json.Marshal(map[string]interface{}{ + "bookmark_id": bookmarkSync.BookmarkId, + "date_created": bookmarkSync.DateCreated, + }) - result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - SelectionText: bookmarkSync.BookmarkText, - StartPosition: bookmarkSync.BookmarkId, - EndPosition: bookmarkSync.BookmarkId, - Color: "#ffff00", - NoteText: bookmarkSync.BookmarkTitle, - Source: "kobo", - DeviceSyncData: deviceData, - }) - if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { - bookmarksSynced++ - } - } else { - h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - SelectionText: bookmarkSync.BookmarkText, - StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - Color: pgtype.Text{String: "#ffff00", Valid: true}, - }) + result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + SelectionText: bookmarkSync.BookmarkText, + StartPosition: bookmarkSync.BookmarkId, + EndPosition: bookmarkSync.BookmarkId, + Color: "#ffff00", + NoteText: bookmarkSync.BookmarkTitle, + Source: "kobo", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { bookmarksSynced++ } } case "bookmark": if bookmarkSync.BookmarkText != "" { - if h.annotationSvc != nil { - deviceData, _ := json.Marshal(map[string]interface{}{ - "bookmark_id": bookmarkSync.BookmarkId, - "date_created": bookmarkSync.DateCreated, - }) + deviceData, _ := json.Marshal(map[string]interface{}{ + "bookmark_id": bookmarkSync.BookmarkId, + "date_created": bookmarkSync.DateCreated, + }) - result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Title: bookmarkSync.BookmarkText, - Position: bookmarkSync.BookmarkId, - ChapterNumber: int32(bookmarkSync.Chapter), - Source: "kobo", - DeviceSyncData: deviceData, - }) - if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { - bookmarksSynced++ - } - } else { - h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Content: bookmarkSync.BookmarkText, - Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - }) + result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + Title: bookmarkSync.BookmarkText, + Position: bookmarkSync.BookmarkId, + ChapterNumber: int32(bookmarkSync.Chapter), + Source: "kobo", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { bookmarksSynced++ } } @@ -842,77 +794,43 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error { for _, bookmark := range syncData.Bookmarks { if bookmark.BookmarkType == "bookmark" { - if h.annotationSvc != nil { - result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Title: bookmark.BookmarkText, - Position: bookmark.BookmarkId, - Source: "kobo", - }) - if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { - bookmarksSent++ - } - } else { - h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Content: bookmark.BookmarkText, - Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, - }) + result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + Title: bookmark.BookmarkText, + Position: bookmark.BookmarkId, + Source: "kobo", + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { bookmarksSent++ } } else if bookmark.BookmarkType == "annotation" { - if h.annotationSvc != nil { - result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - SelectionText: bookmark.BookmarkText, - StartPosition: bookmark.BookmarkId, - EndPosition: bookmark.BookmarkId, - Color: "#ffff00", - Source: "kobo", - }) - if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { - highlightsSent++ - } - } else { - h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - SelectionText: bookmark.BookmarkText, - StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, - EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, - Color: pgtype.Text{String: "#ffff00", Valid: true}, - }) - highlightsSent++ - } - } - } - - for _, highlight := range syncData.Highlights { - if h.annotationSvc != nil { result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, - SelectionText: highlight.BookmarkText, - StartPosition: highlight.BookmarkId, - EndPosition: highlight.BookmarkId, + SelectionText: bookmark.BookmarkText, + StartPosition: bookmark.BookmarkId, + EndPosition: bookmark.BookmarkId, Color: "#ffff00", Source: "kobo", }) if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { highlightsSent++ } - } else { - h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - SelectionText: highlight.BookmarkText, - StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true}, - EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true}, - Color: pgtype.Text{String: "#ffff00", Valid: true}, - }) + } + } + + for _, highlight := range syncData.Highlights { + result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ + MediaItemID: pgMediaUUID, + UserID: pgUserID, + SelectionText: highlight.BookmarkText, + StartPosition: highlight.BookmarkId, + EndPosition: highlight.BookmarkId, + Color: "#ffff00", + Source: "kobo", + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { highlightsSent++ } } diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index 4532ed2d..3af38369 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -36,20 +36,17 @@ type LibraryPathResolver interface { ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) } -func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor, progressSvc *wsync.ProgressService) *KOReaderHandler { +func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService) *KOReaderHandler { return &KOReaderHandler{ - db: db, - connManager: connManager, - queue: queue, - progressSvc: progressSvc, - bookResolver: services.NewBookResolver(db), + db: db, + connManager: connManager, + queue: queue, + progressSvc: progressSvc, + annotationSvc: annotationSvc, + bookResolver: services.NewBookResolver(db), } } -func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) { - h.annotationSvc = svc -} - // annotationEpub carries the per-book context every locator conversion // needs: the media item (format gating) and the resolved EPUB path. It is // resolved once per request so all annotations in a push share one @@ -1489,50 +1486,37 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { position = fmt.Sprintf("page:%d", bookmark.Page) } - if h.annotationSvc != nil { - ec := h.loadAnnotationEpub(ctx, mediaItemID) + ec := h.loadAnnotationEpub(ctx, mediaItemID) - pctLoc := 0.0 - if bookmark.Percentage != nil { - pctLoc = *bookmark.Percentage - } - if pctLoc == 0 { - pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page)) - } - cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc) + pctLoc := 0.0 + if bookmark.Percentage != nil { + pctLoc = *bookmark.Percentage + } + if pctLoc == 0 { + pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page)) + } + cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc) - deviceData, _ := json.Marshal(map[string]interface{}{ - "datetime": bookmark.Datetime, - "pos0": bookmark.Pos0, - "page": bookmark.Page, - }) + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": bookmark.Datetime, + "pos0": bookmark.Pos0, + "page": bookmark.Page, + }) - result, err := h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{ - MediaItemID: mediaItemID, - UserID: pgUserID, - Title: bookmark.Text, - Position: position, - CFIPosition: cfiPosition, - PercentageLoc: pctLoc, - ChapterNumber: int32(bookmark.Chapter), - Source: "koreader", - OriginSource: "koreader", - DeviceSyncData: deviceData, - }) - if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { - bookmarksSynced++ - } - } else { - _, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{ - MediaItemID: mediaItemID, - UserID: pgUserID, - Content: bookmark.Text, - Position: pgtype.Text{String: position, Valid: position != ""}, - }) - - if err == nil { - bookmarksSynced++ - } + result, err := h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{ + MediaItemID: mediaItemID, + UserID: pgUserID, + Title: bookmark.Text, + Position: position, + CFIPosition: cfiPosition, + PercentageLoc: pctLoc, + ChapterNumber: int32(bookmark.Chapter), + Source: "koreader", + OriginSource: "koreader", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + bookmarksSynced++ } } @@ -1554,35 +1538,22 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { position = fmt.Sprintf("page:%d", note.Page) } - if h.annotationSvc != nil { - deviceData, _ := json.Marshal(map[string]interface{}{ - "datetime": note.Datetime, - "pos0": note.Pos0, - "page": note.Page, - }) + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": note.Datetime, + "pos0": note.Pos0, + "page": note.Page, + }) - result, err := h.annotationSvc.SaveNote(ctx, wsync.SaveNoteRequest{ - MediaItemID: mediaItemID, - UserID: pgUserID, - Content: note.Notes, - Position: position, - Source: "koreader", - DeviceSyncData: deviceData, - }) - if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { - notesSynced++ - } - } else { - _, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{ - MediaItemID: mediaItemID, - UserID: pgUserID, - Content: note.Notes, - Position: pgtype.Text{String: position, Valid: position != ""}, - }) - - if err == nil { - notesSynced++ - } + result, err := h.annotationSvc.SaveNote(ctx, wsync.SaveNoteRequest{ + MediaItemID: mediaItemID, + UserID: pgUserID, + Content: note.Notes, + Position: position, + Source: "koreader", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + notesSynced++ } } @@ -1609,53 +1580,38 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { color = mapColorFromKOReader(highlight.Color) } - if h.annotationSvc != nil { - ec := h.loadAnnotationEpub(ctx, mediaItemID) + ec := h.loadAnnotationEpub(ctx, mediaItemID) - pctStart := 0.0 - if highlight.Percentage != nil { - pctStart = *highlight.Percentage - } + pctStart := 0.0 + if highlight.Percentage != nil { + pctStart = *highlight.Percentage + } - epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, highlight.Pos0, highlight.Pos1, highlight.Text, pctStart) + epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, highlight.Pos0, highlight.Pos1, highlight.Text, pctStart) - deviceData, _ := json.Marshal(map[string]interface{}{ - "datetime": highlight.Datetime, - "pos0": highlight.Pos0, - "pos1": highlight.Pos1, - "page": highlight.Page, - }) + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": highlight.Datetime, + "pos0": highlight.Pos0, + "pos1": highlight.Pos1, + "page": highlight.Page, + }) - result, err := h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ - MediaItemID: mediaItemID, - UserID: pgUserID, - SelectionText: highlight.Text, - StartPosition: startPos, - EndPosition: endPos, - Color: color, - NoteText: highlight.Notes, - PercentageStart: pctStart, - EpubcfiStart: epubcfiStart, - EpubcfiEnd: epubcfiEnd, - Source: "koreader", - DeviceSyncData: deviceData, - }) - if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { - highlightsSynced++ - } - } else { - _, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{ - MediaItemID: mediaItemID, - UserID: pgUserID, - SelectionText: highlight.Text, - StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""}, - EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""}, - Color: pgtype.Text{String: color, Valid: true}, - }) - - if err == nil { - highlightsSynced++ - } + result, err := h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ + MediaItemID: mediaItemID, + UserID: pgUserID, + SelectionText: highlight.Text, + StartPosition: startPos, + EndPosition: endPos, + Color: color, + NoteText: highlight.Notes, + PercentageStart: pctStart, + EpubcfiStart: epubcfiStart, + EpubcfiEnd: epubcfiEnd, + Source: "koreader", + DeviceSyncData: deviceData, + }) + if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { + highlightsSynced++ } } diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 451e14ff..ae0dd766 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -183,12 +183,13 @@ type MediaHandler struct { // NewMediaHandler requires the shared ProgressService — progress writes // must funnel through SaveProgress (the LWW-seeded canonical writer); // a nil service has nowhere to fall back to and must not be wired. -func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, worker ...*services.Worker) *MediaHandler { +func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler { mh := &MediaHandler{ db: db, libraryService: libraryService, searchService: services.NewSearchService(db), progressSvc: progressSvc, + annotationSvc: annotationSvc, } if len(worker) > 0 && worker[0] != nil { mh.worker = worker[0] @@ -196,10 +197,6 @@ func NewMediaHandler(db *database.Queries, libraryService *services.LibraryServi return mh } -func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) { - mh.annotationSvc = svc -} - // ExecuteSearch performs search and returns results with count // Public wrapper for shared search logic used by both JSON and HTML endpoints func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) { @@ -1197,22 +1194,22 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error { // additive keys per docs/contract_pins.md (H1). func progressResponse(progress database.GetUniversalProgressRow) map[string]interface{} { return map[string]interface{}{ - "id": progress.ID, - "media_item_id": progress.MediaItemID, - "user_id": progress.UserID, - "current_page": progress.CurrentPage, - "total_pages": progress.TotalPages, - "last_read_at": progress.LastReadAt, - "percentage": progress.Percentage, - "character_offset": progress.CharacterOffset, - "epubcfi": progress.Epubcfi, - "chapter": progress.Chapter, - "chapter_progress": progress.ChapterProgress, - "format_group": progress.FormatGroup, - "total_characters": progress.TotalCharacters, - "chapter_count": progress.ChapterCount, - "last_sync_device": progress.LastSyncDevice, - "last_sync_source": progress.LastSyncSource, + "id": progress.ID, + "media_item_id": progress.MediaItemID, + "user_id": progress.UserID, + "current_page": progress.CurrentPage, + "total_pages": progress.TotalPages, + "last_read_at": progress.LastReadAt, + "percentage": progress.Percentage, + "character_offset": progress.CharacterOffset, + "epubcfi": progress.Epubcfi, + "chapter": progress.Chapter, + "chapter_progress": progress.ChapterProgress, + "format_group": progress.FormatGroup, + "total_characters": progress.TotalCharacters, + "chapter_count": progress.ChapterCount, + "last_sync_device": progress.LastSyncDevice, + "last_sync_source": progress.LastSyncSource, "last_sync_timestamp": progress.LastSyncTimestamp, // Row-carried fields surfaced additively (H1) — the web audiobook // reader already reads audio_offset_seconds (audio.ts) and the @@ -1830,34 +1827,19 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - var note database.MediaNotes - if mh.annotationSvc != nil { - result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{ - MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, - UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, - Content: req.Content, - Position: req.Position, - Source: "web", - ModifiedAt: time.Now(), - }) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - note = result.Note - } else { - var err error - note, err = mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ - MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, - UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, - Content: req.Content, - Position: pgtype.Text{String: req.Position, Valid: req.Position != ""}, - }) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } + result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{ + MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + Content: req.Content, + Position: req.Position, + Source: "web", + ModifiedAt: time.Now(), + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - return c.JSON(http.StatusCreated, note) + return c.JSON(http.StatusCreated, result.Note) } // GetMediaNote handles GET /api/media-items/:id/notes/:noteId @@ -1928,15 +1910,7 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } - if mh.annotationSvc != nil { - err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true}) - } else { - err = mh.db.DeleteMediaNote(c.Request().Context(), database.DeleteMediaNoteParams{ - ID: pgtype.UUID{Bytes: noteUUID, Valid: true}, - UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, - }) - } - if err != nil { + if err := mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true}); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } @@ -1990,15 +1964,6 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - var noteUUID pgtype.UUID - if req.NoteID != "" { - if noteID, err := uuid.Parse(req.NoteID); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) - } else { - noteUUID = pgtype.UUID{Bytes: noteID, Valid: true} - } - } - color := "#ffff00" if req.Color != "" { color = req.Color @@ -2007,43 +1972,26 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error { pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true} pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true} - if mh.annotationSvc != nil { - result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ - MediaItemID: pgMediaID, - UserID: pgUserID, - SelectionText: req.SelectionText, - StartPosition: req.StartPosition, - EndPosition: req.EndPosition, - EpubcfiStart: req.EpubcfiStart, - EpubcfiEnd: req.EpubcfiEnd, - Color: color, - NoteText: req.NoteText, - PercentageStart: req.PercentageStart, - PercentageEnd: req.PercentageEnd, - ChapterReference: req.ChapterReference, - Source: "web", - ModifiedAt: time.Now(), - }) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - return c.JSON(http.StatusCreated, result.Highlight) - } - - highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ - MediaItemID: pgMediaID, - UserID: pgUserID, - SelectionText: req.SelectionText, - StartPosition: pgtype.Text{String: req.StartPosition, Valid: true}, - EndPosition: pgtype.Text{String: req.EndPosition, Valid: true}, - Color: pgtype.Text{String: color, Valid: true}, - NoteID: noteUUID, + result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ + MediaItemID: pgMediaID, + UserID: pgUserID, + SelectionText: req.SelectionText, + StartPosition: req.StartPosition, + EndPosition: req.EndPosition, + EpubcfiStart: req.EpubcfiStart, + EpubcfiEnd: req.EpubcfiEnd, + Color: color, + NoteText: req.NoteText, + PercentageStart: req.PercentageStart, + PercentageEnd: req.PercentageEnd, + ChapterReference: req.ChapterReference, + Source: "web", + ModifiedAt: time.Now(), }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - - return c.JSON(http.StatusCreated, highlight) + return c.JSON(http.StatusCreated, result.Highlight) } // GetMediaHighlight handles GET /api/media-items/:id/highlights/:highlightId @@ -2081,81 +2029,46 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - var noteUUID pgtype.UUID - if req.NoteID != "" { - if noteID, err := uuid.Parse(req.NoteID); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) - } else { - noteUUID = pgtype.UUID{Bytes: noteID, Valid: true} - } - } - color := "#ffff00" if req.Color != "" { color = req.Color } - // Prefer the sync-aware path: the same selection text + CFI resolves to - // the same dedup key, so this performs an LWW update of the existing row - // (including note_text and CFI columns the plain query cannot touch). - if mh.annotationSvc != nil { - userID := c.Get("user_id").(string) - userUUID, err := uuid.Parse(userID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) - } - mediaID := c.Param("id") - mediaUUID, err := uuid.Parse(mediaID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) - } - result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ - MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, - UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, - SelectionText: req.SelectionText, - StartPosition: req.StartPosition, - EndPosition: req.EndPosition, - EpubcfiStart: req.EpubcfiStart, - EpubcfiEnd: req.EpubcfiEnd, - Color: color, - NoteText: req.NoteText, - PercentageStart: req.PercentageStart, - PercentageEnd: req.PercentageEnd, - ChapterReference: req.ChapterReference, - Source: "web", - ModifiedAt: time.Now(), - // The PUT targets this exact row (from the URL): identity must - // not be re-derived from content — a device echo has usually - // rewritten the stored CFI to point shape, so the computed key - // would miss and mint a duplicate beside the edited row. - HighlightID: pgtype.UUID{Bytes: highlightUUID, Valid: true}, - }) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - return c.JSON(http.StatusOK, result.Highlight) - } - userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } - - highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{ - ID: pgtype.UUID{Bytes: highlightUUID, Valid: true}, - SelectionText: req.SelectionText, - StartPosition: pgtype.Text{String: req.StartPosition, Valid: true}, - EndPosition: pgtype.Text{String: req.EndPosition, Valid: true}, - Color: pgtype.Text{String: color, Valid: true}, - NoteID: noteUUID, - UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + mediaID := c.Param("id") + mediaUUID, err := uuid.Parse(mediaID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) + } + result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ + MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + SelectionText: req.SelectionText, + StartPosition: req.StartPosition, + EndPosition: req.EndPosition, + EpubcfiStart: req.EpubcfiStart, + EpubcfiEnd: req.EpubcfiEnd, + Color: color, + NoteText: req.NoteText, + PercentageStart: req.PercentageStart, + PercentageEnd: req.PercentageEnd, + ChapterReference: req.ChapterReference, + Source: "web", + ModifiedAt: time.Now(), + // The PUT targets this exact row (from the URL): identity must + // not be re-derived from content — a device echo has usually + // rewritten the stored CFI to point shape, so the computed key + // would miss and mint a duplicate beside the edited row. + HighlightID: pgtype.UUID{Bytes: highlightUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - - return c.JSON(http.StatusOK, highlight) + return c.JSON(http.StatusOK, result.Highlight) } // DeleteMediaHighlight handles DELETE /api/media-items/:id/highlights/:highlightId @@ -2175,24 +2088,12 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error { } pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true} - if mh.annotationSvc != nil { - if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgUserID, pgHighlightID, "web"); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"}) - } - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgUserID, pgHighlightID, "web"); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"}) } - return c.NoContent(http.StatusNoContent) - } - - err = mh.db.DeleteMediaHighlight(c.Request().Context(), database.DeleteMediaHighlightParams{ - ID: pgHighlightID, - UserID: pgUserID, - }) - if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - return c.NoContent(http.StatusNoContent) } @@ -2243,48 +2144,31 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - // The sync-aware path (dedup + LWW + tombstones) is preferred; fall back - // to the plain query when the service isn't wired (e.g. some tests). - if mh.annotationSvc != nil { - origin := req.Origin - if origin == "" { - origin = "web" - } - result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ - MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, - UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, - Title: req.Title, - Position: req.Position, - Notes: req.Notes, - PageNumber: req.PageNumber, - ChapterNumber: req.ChapterNumber, - CFIPosition: req.CfiPosition, - PercentageLoc: req.Percentage, - ChapterReference: req.ChapterReference, - Source: "web", - OriginSource: origin, - ModifiedAt: time.Now(), - }) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - return c.JSON(http.StatusCreated, result.Bookmark) + // The sync-aware path (dedup + LWW + tombstones) is the only path: the + // annotation service is a required constructor dependency. + origin := req.Origin + if origin == "" { + origin = "web" } - - bookmark, err := mh.db.CreateMediaBookmark(c.Request().Context(), database.CreateMediaBookmarkParams{ - MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, - UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, - PageNumber: pgtype.Int4{Int32: req.PageNumber, Valid: req.PageNumber > 0}, - ChapterNumber: pgtype.Int4{Int32: req.ChapterNumber, Valid: req.ChapterNumber > 0}, - CfiPosition: pgtype.Text{String: req.CfiPosition, Valid: req.CfiPosition != ""}, - Title: req.Title, - Position: pgtype.Text{String: req.Position, Valid: req.Position != ""}, - Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""}, + result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ + MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + Title: req.Title, + Position: req.Position, + Notes: req.Notes, + PageNumber: req.PageNumber, + ChapterNumber: req.ChapterNumber, + CFIPosition: req.CfiPosition, + PercentageLoc: req.Percentage, + ChapterReference: req.ChapterReference, + Source: "web", + OriginSource: origin, + ModifiedAt: time.Now(), }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - return c.JSON(http.StatusCreated, bookmark) + return c.JSON(http.StatusCreated, result.Bookmark) } // UpdateMediaBookmark handles PUT /api/media-items/:id/bookmarks/:bookmarkId @@ -2340,23 +2224,12 @@ func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error { } pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true} - if mh.annotationSvc != nil { - if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgUserID, pgBookmarkID, "web"); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return c.JSON(http.StatusNotFound, map[string]string{"error": "bookmark not found"}) - } - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgUserID, pgBookmarkID, "web"); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "bookmark not found"}) } - return c.NoContent(http.StatusNoContent) - } - - if err := mh.db.DeleteMediaBookmark(c.Request().Context(), database.DeleteMediaBookmarkParams{ - ID: pgBookmarkID, - UserID: pgUserID, - }); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - return c.NoContent(http.StatusNoContent) } diff --git a/internal/router/sync.go b/internal/router/sync.go index bafe94bc..aad42851 100644 --- a/internal/router/sync.go +++ b/internal/router/sync.go @@ -37,8 +37,7 @@ func registerSyncRoutes(cfg *Config) { // Kobo sync routes (device authentication required) // Kobo devices use URL path: /api/sync/kobo/{token}/markup // API clients can use Authorization header: Authorization: Bearer {token} - koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager, cfg.ProgressService) - koboHandler.SetAnnotationService(cfg.AnnotationService) + koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager, cfg.ProgressService, cfg.AnnotationService) koboHandler.SetLibraryService(cfg.LibraryService) koboSync := e.Group("/api/sync/kobo/:token") koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup)) diff --git a/internal/sync/annotations.go b/internal/sync/annotations.go index 3d5bcdd7..f062b38b 100644 --- a/internal/sync/annotations.go +++ b/internal/sync/annotations.go @@ -73,15 +73,15 @@ type SaveHighlightRequest struct { // 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 + 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 diff --git a/internal/sync/queue.go b/internal/sync/queue.go index 2fbef285..992af511 100644 --- a/internal/sync/queue.go +++ b/internal/sync/queue.go @@ -76,27 +76,24 @@ type SyncQueueItem struct { // NewSyncQueueProcessor requires the shared ProgressService — queue // progress entries flush through SaveProgress (the LWW-seeded canonical // writer); a nil service has nowhere to fall back to and must not be wired. -func NewSyncQueueProcessor(db *database.Queries, progressSvc *ProgressService) *SyncQueueProcessor { - return NewSyncQueueProcessorWithConfig(db, progressSvc, 5*time.Second, 50) +func NewSyncQueueProcessor(db *database.Queries, progressSvc *ProgressService, annotationSvc *AnnotationService) *SyncQueueProcessor { + return NewSyncQueueProcessorWithConfig(db, progressSvc, annotationSvc, 5*time.Second, 50) } // NewSyncQueueProcessorWithConfig constructs a processor with the given flush // interval and batch size. Used at startup to source values from the settings // registry. -func NewSyncQueueProcessorWithConfig(db *database.Queries, progressSvc *ProgressService, interval time.Duration, batchSize int) *SyncQueueProcessor { +func NewSyncQueueProcessorWithConfig(db *database.Queries, progressSvc *ProgressService, annotationSvc *AnnotationService, interval time.Duration, batchSize int) *SyncQueueProcessor { return &SyncQueueProcessor{ - db: db, - progressSvc: progressSvc, - progressChan: make(chan *ProgressUpdate, 100), - interval: interval, - batchSize: batchSize, + db: db, + progressSvc: progressSvc, + annotationSvc: annotationSvc, + progressChan: make(chan *ProgressUpdate, 100), + interval: interval, + batchSize: batchSize, } } -func (p *SyncQueueProcessor) SetAnnotationService(svc *AnnotationService) { - p.annotationSvc = svc -} - func (p *SyncQueueProcessor) Start(ctx context.Context) { log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize) @@ -461,10 +458,6 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI } func (p *SyncQueueProcessor) syncNote(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error { - if p.annotationSvc == nil { - return fmt.Errorf("annotation service not available") - } - req := SaveNoteRequest{ MediaItemID: mediaItemID, UserID: userID, @@ -497,10 +490,6 @@ func (p *SyncQueueProcessor) syncNote(ctx context.Context, userID pgtype.UUID, m } func (p *SyncQueueProcessor) syncHighlight(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error { - if p.annotationSvc == nil { - return fmt.Errorf("annotation service not available") - } - req := SaveHighlightRequest{ MediaItemID: mediaItemID, UserID: userID, @@ -548,10 +537,6 @@ func (p *SyncQueueProcessor) syncHighlight(ctx context.Context, userID pgtype.UU } func (p *SyncQueueProcessor) syncBookmark(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error { - if p.annotationSvc == nil { - return fmt.Errorf("annotation service not available") - } - req := SaveBookmarkRequest{ MediaItemID: mediaItemID, UserID: userID, diff --git a/internal/sync/queue_test.go b/internal/sync/queue_test.go index 669f936f..2dd737db 100644 --- a/internal/sync/queue_test.go +++ b/internal/sync/queue_test.go @@ -8,7 +8,7 @@ import ( ) func TestCalculateNextRetry(t *testing.T) { - processor := NewSyncQueueProcessor(nil, nil) + processor := NewSyncQueueProcessor(nil, nil, nil) tests := []struct { name string