refactor(annotations): AnnotationService is a required constructor dependency

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.
This commit is contained in:
John O'Keefe committed 2026-10-17 15:47:57 -04:00
1 parent ff029f1ad5
commit c579179fee
8 files changed
+23 -297

No files matched your search

+3 -6
View File
@@ -130,8 +130,7 @@ func main() {
maintenanceCancel := annotationService.StartDailyMaintenance() maintenanceCancel := annotationService.StartDailyMaintenance()
defer maintenanceCancel() defer maintenanceCancel()
queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, progressService, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize) queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, progressService, annotationService, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize)
queueProcessor.SetAnnotationService(annotationService)
// Create library service // Create library service
libraryService := services.NewLibraryService(queries) libraryService := services.NewLibraryService(queries)
@@ -147,8 +146,7 @@ func main() {
worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager) worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager)
services.WorkerInstance = worker services.WorkerInstance = worker
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor, progressService) koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor, progressService, annotationService)
koreaderHandler.SetAnnotationService(annotationService)
koreaderHandler.SetLibraryService(libraryService) koreaderHandler.SetLibraryService(libraryService)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager, progressService) conflictHandler := handlers.NewConflictHandler(queries, connManager, progressService)
@@ -167,8 +165,7 @@ func main() {
dashboardHandler := handlers.NewDashboardHandler(queries) dashboardHandler := handlers.NewDashboardHandler(queries)
seriesHandler := handlers.NewSeriesHandler(queries) seriesHandler := handlers.NewSeriesHandler(queries)
filtersHandler := handlers.NewFiltersHandler(queries) filtersHandler := handlers.NewFiltersHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService) mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService)
mediaHandler.SetAnnotationService(annotationService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager) matchingHandler := handlers.NewMatchingHandler(queries, connManager)
jobsHandler := handlers.NewJobsHandler(queries, worker) jobsHandler := handlers.NewJobsHandler(queries, worker)
+3 -5
View File
@@ -483,12 +483,11 @@ func setupTestServer(t *testing.T) *TestServerSetup {
progressService := wsync.NewProgressService(queries, connManager) progressService := wsync.NewProgressService(queries, connManager)
annotationService := wsync.NewAnnotationService(queries, connManager) annotationService := wsync.NewAnnotationService(queries, connManager)
queueProcessor := wsync.NewSyncQueueProcessor(queries, progressService) queueProcessor := wsync.NewSyncQueueProcessor(queries, progressService, annotationService)
queueCtx, queueCancel := context.WithCancel(context.Background()) queueCtx, queueCancel := context.WithCancel(context.Background())
go queueProcessor.Start(queueCtx) go queueProcessor.Start(queueCtx)
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor, progressService) koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor, progressService, annotationService)
koreaderHandler.SetAnnotationService(annotationService)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager, progressService) conflictHandler := handlers.NewConflictHandler(queries, connManager, progressService)
analyticsHandler := handlers.NewAnalyticsHandler(queries) analyticsHandler := handlers.NewAnalyticsHandler(queries)
@@ -508,8 +507,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
statisticsService := services.NewStatisticsService(queries) statisticsService := services.NewStatisticsService(queries)
statsHandler := handlers.NewStatsHandler(statisticsService) statsHandler := handlers.NewStatsHandler(statisticsService)
seriesHandler := handlers.NewSeriesHandler(queries) seriesHandler := handlers.NewSeriesHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, worker) mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService, worker)
mediaHandler.SetAnnotationService(annotationService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager) matchingHandler := handlers.NewMatchingHandler(queries, connManager)
// Create conversion service for OPDS // Create conversion service for OPDS
+3 -85
View File
@@ -26,12 +26,8 @@ type KoboHandler struct {
bookResolver *services.BookResolver bookResolver *services.BookResolver
} }
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager, progressSvc *wsync.ProgressService) *KoboHandler { func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService) *KoboHandler {
return &KoboHandler{db: db, connManager: connManager, progressSvc: progressSvc, bookResolver: services.NewBookResolver(db)} return &KoboHandler{db: db, connManager: connManager, progressSvc: progressSvc, annotationSvc: annotationSvc, bookResolver: services.NewBookResolver(db)}
}
func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) {
h.annotationSvc = svc
} }
func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) { func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) {
@@ -475,7 +471,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
switch bookmarkSync.BookmarkType { switch bookmarkSync.BookmarkType {
case "annotation": case "annotation":
if bookmarkSync.BookmarkText != "" { if bookmarkSync.BookmarkText != "" {
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{ deviceData, _ := json.Marshal(map[string]interface{}{
"bookmark_id": bookmarkSync.BookmarkId, "bookmark_id": bookmarkSync.BookmarkId,
"date_created": bookmarkSync.DateCreated, "date_created": bookmarkSync.DateCreated,
@@ -495,21 +490,9 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++ 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},
})
bookmarksSynced++
}
} }
case "bookmark": case "bookmark":
if bookmarkSync.BookmarkText != "" { if bookmarkSync.BookmarkText != "" {
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{ deviceData, _ := json.Marshal(map[string]interface{}{
"bookmark_id": bookmarkSync.BookmarkId, "bookmark_id": bookmarkSync.BookmarkId,
"date_created": bookmarkSync.DateCreated, "date_created": bookmarkSync.DateCreated,
@@ -527,15 +510,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++ 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},
})
bookmarksSynced++
}
} }
case "last-read-place": case "last-read-place":
if bookmarkSync.BookmarkId != "" { if bookmarkSync.BookmarkId != "" {
@@ -600,7 +574,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
BookmarksSynced: bookmarksSynced, 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} cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
for mediaItemID, contentId := range processedBooks { for mediaItemID, contentId := range processedBooks {
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{ tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
@@ -668,7 +642,6 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
switch bookmarkSync.BookmarkType { switch bookmarkSync.BookmarkType {
case "annotation": case "annotation":
if bookmarkSync.BookmarkText != "" { if bookmarkSync.BookmarkText != "" {
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{ deviceData, _ := json.Marshal(map[string]interface{}{
"bookmark_id": bookmarkSync.BookmarkId, "bookmark_id": bookmarkSync.BookmarkId,
"date_created": bookmarkSync.DateCreated, "date_created": bookmarkSync.DateCreated,
@@ -688,21 +661,9 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++ 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},
})
bookmarksSynced++
}
} }
case "bookmark": case "bookmark":
if bookmarkSync.BookmarkText != "" { if bookmarkSync.BookmarkText != "" {
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{ deviceData, _ := json.Marshal(map[string]interface{}{
"bookmark_id": bookmarkSync.BookmarkId, "bookmark_id": bookmarkSync.BookmarkId,
"date_created": bookmarkSync.DateCreated, "date_created": bookmarkSync.DateCreated,
@@ -720,15 +681,6 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++ 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},
})
bookmarksSynced++
}
} }
} }
} }
@@ -842,7 +794,6 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
for _, bookmark := range syncData.Bookmarks { for _, bookmark := range syncData.Bookmarks {
if bookmark.BookmarkType == "bookmark" { if bookmark.BookmarkType == "bookmark" {
if h.annotationSvc != nil {
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgMediaUUID, MediaItemID: pgMediaUUID,
UserID: pgUserID, UserID: pgUserID,
@@ -853,17 +804,7 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSent++ 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},
})
bookmarksSent++
}
} else if bookmark.BookmarkType == "annotation" { } else if bookmark.BookmarkType == "annotation" {
if h.annotationSvc != nil {
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgMediaUUID, MediaItemID: pgMediaUUID,
UserID: pgUserID, UserID: pgUserID,
@@ -876,22 +817,10 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
highlightsSent++ 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 { for _, highlight := range syncData.Highlights {
if h.annotationSvc != nil {
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgMediaUUID, MediaItemID: pgMediaUUID,
UserID: pgUserID, UserID: pgUserID,
@@ -904,17 +833,6 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
highlightsSent++ 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},
})
highlightsSent++
}
} }
} }
+2 -46
View File
@@ -36,20 +36,17 @@ type LibraryPathResolver interface {
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) 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{ return &KOReaderHandler{
db: db, db: db,
connManager: connManager, connManager: connManager,
queue: queue, queue: queue,
progressSvc: progressSvc, progressSvc: progressSvc,
annotationSvc: annotationSvc,
bookResolver: services.NewBookResolver(db), bookResolver: services.NewBookResolver(db),
} }
} }
func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
h.annotationSvc = svc
}
// annotationEpub carries the per-book context every locator conversion // annotationEpub carries the per-book context every locator conversion
// needs: the media item (format gating) and the resolved EPUB path. It is // 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 // resolved once per request so all annotations in a push share one
@@ -1489,7 +1486,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
position = fmt.Sprintf("page:%d", bookmark.Page) position = fmt.Sprintf("page:%d", bookmark.Page)
} }
if h.annotationSvc != nil {
ec := h.loadAnnotationEpub(ctx, mediaItemID) ec := h.loadAnnotationEpub(ctx, mediaItemID)
pctLoc := 0.0 pctLoc := 0.0
@@ -1522,18 +1518,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++ 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++
}
}
} }
for _, note := range req.Notes { for _, note := range req.Notes {
@@ -1554,7 +1538,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
position = fmt.Sprintf("page:%d", note.Page) position = fmt.Sprintf("page:%d", note.Page)
} }
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{ deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": note.Datetime, "datetime": note.Datetime,
"pos0": note.Pos0, "pos0": note.Pos0,
@@ -1572,18 +1555,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
notesSynced++ 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++
}
}
} }
for _, highlight := range req.Highlights { for _, highlight := range req.Highlights {
@@ -1609,7 +1580,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
color = mapColorFromKOReader(highlight.Color) color = mapColorFromKOReader(highlight.Color)
} }
if h.annotationSvc != nil {
ec := h.loadAnnotationEpub(ctx, mediaItemID) ec := h.loadAnnotationEpub(ctx, mediaItemID)
pctStart := 0.0 pctStart := 0.0
@@ -1643,20 +1613,6 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
highlightsSynced++ 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++
}
}
} }
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
+6 -133
View File
@@ -183,12 +183,13 @@ type MediaHandler struct {
// NewMediaHandler requires the shared ProgressService — progress writes // NewMediaHandler requires the shared ProgressService — progress writes
// must funnel through SaveProgress (the LWW-seeded canonical writer); // must funnel through SaveProgress (the LWW-seeded canonical writer);
// a nil service has nowhere to fall back to and must not be wired. // 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{ mh := &MediaHandler{
db: db, db: db,
libraryService: libraryService, libraryService: libraryService,
searchService: services.NewSearchService(db), searchService: services.NewSearchService(db),
progressSvc: progressSvc, progressSvc: progressSvc,
annotationSvc: annotationSvc,
} }
if len(worker) > 0 && worker[0] != nil { if len(worker) > 0 && worker[0] != nil {
mh.worker = worker[0] mh.worker = worker[0]
@@ -196,10 +197,6 @@ func NewMediaHandler(db *database.Queries, libraryService *services.LibraryServi
return mh return mh
} }
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
mh.annotationSvc = svc
}
// ExecuteSearch performs search and returns results with count // ExecuteSearch performs search and returns results with count
// Public wrapper for shared search logic used by both JSON and HTML endpoints // 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) { func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
@@ -1830,8 +1827,6 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.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{ result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
@@ -1843,21 +1838,8 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 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()})
}
}
return c.JSON(http.StatusCreated, note) return c.JSON(http.StatusCreated, result.Note)
} }
// GetMediaNote handles GET /api/media-items/:id/notes/:noteId // 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"}) return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
} }
if mh.annotationSvc != nil { if err := mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true}); err != 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 {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 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()}) 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" color := "#ffff00"
if req.Color != "" { if req.Color != "" {
color = req.Color color = req.Color
@@ -2007,7 +1972,6 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true} pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true} pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
if mh.annotationSvc != nil {
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgMediaID, MediaItemID: pgMediaID,
UserID: pgUserID, UserID: pgUserID,
@@ -2028,22 +1992,6 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
return c.JSON(http.StatusCreated, result.Highlight) 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,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, highlight)
} }
// GetMediaHighlight handles GET /api/media-items/:id/highlights/:highlightId // GetMediaHighlight handles GET /api/media-items/:id/highlights/:highlightId
@@ -2081,24 +2029,11 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.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" color := "#ffff00"
if req.Color != "" { if req.Color != "" {
color = 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) userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID) userUUID, err := uuid.Parse(userID)
if err != nil { if err != nil {
@@ -2134,28 +2069,6 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
return c.JSON(http.StatusOK, result.Highlight) 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},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, highlight)
} }
// DeleteMediaHighlight handles DELETE /api/media-items/:id/highlights/:highlightId // 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} 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 err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgUserID, pgHighlightID, "web"); err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
} }
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
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) return c.NoContent(http.StatusNoContent)
} }
@@ -2243,9 +2144,8 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
} }
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back // The sync-aware path (dedup + LWW + tombstones) is the only path: the
// to the plain query when the service isn't wired (e.g. some tests). // annotation service is a required constructor dependency.
if mh.annotationSvc != nil {
origin := req.Origin origin := req.Origin
if origin == "" { if origin == "" {
origin = "web" origin = "web"
@@ -2269,22 +2169,6 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
return c.JSON(http.StatusCreated, result.Bookmark) return c.JSON(http.StatusCreated, result.Bookmark)
}
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 != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, bookmark)
} }
// UpdateMediaBookmark handles PUT /api/media-items/:id/bookmarks/:bookmarkId // 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} 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 err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgUserID, pgBookmarkID, "web"); err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "bookmark not found"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "bookmark not found"})
} }
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
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) return c.NoContent(http.StatusNoContent)
} }
+1 -2
View File
@@ -37,8 +37,7 @@ func registerSyncRoutes(cfg *Config) {
// Kobo sync routes (device authentication required) // Kobo sync routes (device authentication required)
// Kobo devices use URL path: /api/sync/kobo/{token}/markup // Kobo devices use URL path: /api/sync/kobo/{token}/markup
// API clients can use Authorization header: Authorization: Bearer {token} // API clients can use Authorization header: Authorization: Bearer {token}
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager, cfg.ProgressService) koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager, cfg.ProgressService, cfg.AnnotationService)
koboHandler.SetAnnotationService(cfg.AnnotationService)
koboHandler.SetLibraryService(cfg.LibraryService) koboHandler.SetLibraryService(cfg.LibraryService)
koboSync := e.Group("/api/sync/kobo/:token") koboSync := e.Group("/api/sync/kobo/:token")
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup)) koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
+4 -19
View File
@@ -76,27 +76,24 @@ type SyncQueueItem struct {
// NewSyncQueueProcessor requires the shared ProgressService — queue // NewSyncQueueProcessor requires the shared ProgressService — queue
// progress entries flush through SaveProgress (the LWW-seeded canonical // progress entries flush through SaveProgress (the LWW-seeded canonical
// writer); a nil service has nowhere to fall back to and must not be wired. // writer); a nil service has nowhere to fall back to and must not be wired.
func NewSyncQueueProcessor(db *database.Queries, progressSvc *ProgressService) *SyncQueueProcessor { func NewSyncQueueProcessor(db *database.Queries, progressSvc *ProgressService, annotationSvc *AnnotationService) *SyncQueueProcessor {
return NewSyncQueueProcessorWithConfig(db, progressSvc, 5*time.Second, 50) return NewSyncQueueProcessorWithConfig(db, progressSvc, annotationSvc, 5*time.Second, 50)
} }
// NewSyncQueueProcessorWithConfig constructs a processor with the given flush // NewSyncQueueProcessorWithConfig constructs a processor with the given flush
// interval and batch size. Used at startup to source values from the settings // interval and batch size. Used at startup to source values from the settings
// registry. // 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{ return &SyncQueueProcessor{
db: db, db: db,
progressSvc: progressSvc, progressSvc: progressSvc,
annotationSvc: annotationSvc,
progressChan: make(chan *ProgressUpdate, 100), progressChan: make(chan *ProgressUpdate, 100),
interval: interval, interval: interval,
batchSize: batchSize, batchSize: batchSize,
} }
} }
func (p *SyncQueueProcessor) SetAnnotationService(svc *AnnotationService) {
p.annotationSvc = svc
}
func (p *SyncQueueProcessor) Start(ctx context.Context) { func (p *SyncQueueProcessor) Start(ctx context.Context) {
log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize) 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 { 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{ req := SaveNoteRequest{
MediaItemID: mediaItemID, MediaItemID: mediaItemID,
UserID: userID, 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 { 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{ req := SaveHighlightRequest{
MediaItemID: mediaItemID, MediaItemID: mediaItemID,
UserID: userID, 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 { 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{ req := SaveBookmarkRequest{
MediaItemID: mediaItemID, MediaItemID: mediaItemID,
UserID: userID, UserID: userID,
+1 -1
View File
@@ -8,7 +8,7 @@ import (
) )
func TestCalculateNextRetry(t *testing.T) { func TestCalculateNextRetry(t *testing.T) {
processor := NewSyncQueueProcessor(nil, nil) processor := NewSyncQueueProcessor(nil, nil, nil)
tests := []struct { tests := []struct {
name string name string