From 75b33fdae6a993f0ceaa8ddc3c75d3d7ca94db7c Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 29 Jul 2026 14:49:19 -0400 Subject: [PATCH] feat(sync): wire annotation sync into all device and web handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the annotation sync pipeline across all ingest and serve paths. Previously, annotations sent inline with KOReader progress pushes were silently discarded, and no annotations were ever served back to devices. INGEST (device → server): KOReader (koreader.go): - Add processBookAnnotations helper that processes inline highlights, notes, and bookmarks from every progress push (immediate + checkpoint) - Highlights get CRE→CFI position conversion before SaveHighlight - KOReader 'notes' (text + notes) stored as highlights with NoteText to ensure correct round-trip classification - Bookmarks routed through SaveBookmark with device sync data - Called from both updateProgressForBook and handleCheckpointSync Kobo (kobo.go): - Markup handler: annotations and bookmarks route through AnnotationService (SaveHighlight/SaveBookmark) - Bookmark handler: same routing with device sync data - SyncFromServer handler: same routing - All handlers fall back to direct DB calls when annotationSvc == nil Web reader (media.go): - CreateMediaHighlight → SaveHighlight (Source="web", ModifiedAt=now) - CreateMediaNote → SaveNote (Source="web") - DeleteMediaHighlight → TombstoneHighlightByID - DeleteMediaNote → TombstoneNoteByID (was hard delete, now tombstone) - All fall back to old behavior when annotationSvc == nil SERVE (server → device): KOReader GetMetadata (koreader.go): - Query and serve bookmarks from media_bookmarks table (was missing) - Serve deleted_highlights and deleted_bookmarks arrays containing device_sync_data + dedup_key for client-side deletion - Highlights/notes already served with reverse CFI conversion Kobo Markup handler (kobo.go): - Track processed books during sync - Query tombstones per book, extract bookmark_id from device_sync_data - Return DeletedAnnotations array in KoboSyncStatus response Conflict resolution (conflicts.go): - Enable annotation conflict types in ResolveConflict handler - Add applyAnnotationResolution dispatching to: applyHighlightResolution / applyBookmarkResolution / applyNoteResolution - Each looks up by dedup_key and applies winner's fields - Allow manual override of auto_resolved conflicts (changed check from != "unresolved" to == "user_resolved") Infrastructure: - AnnotationService field + SetAnnotationService in router Config - Inject AnnotationService into KOReader, Kobo, Media handlers - Start tombstone purger goroutine in main.go (24h interval) - Test helpers: construct AnnotationService in test setup --- cmd/server/main.go | 7 + cmd/server/tests/test_helpers_test.go | 5 + internal/handlers/conflicts.go | 145 ++++++++++- internal/handlers/kobo.go | 291 ++++++++++++++++----- internal/handlers/koreader.go | 352 +++++++++++++++++++++++--- internal/handlers/media.go | 80 +++++- internal/router/router.go | 1 + internal/router/sync.go | 1 + 8 files changed, 772 insertions(+), 110 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index ea041a3..6ccd173 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -63,9 +63,13 @@ func main() { connManager := sync.NewConnectionManager() progressService := sync.NewProgressService(queries, connManager) + annotationService := sync.NewAnnotationService(queries, connManager) + tombstonePurgerCancel := annotationService.StartTombstonePurger() + defer tombstonePurgerCancel() queueProcessor := sync.NewSyncQueueProcessor(queries) queueProcessor.SetProgressService(progressService) + queueProcessor.SetAnnotationService(annotationService) // Create library service libraryService := services.NewLibraryService(queries) @@ -79,6 +83,7 @@ func main() { koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) koreaderHandler.SetProgressService(progressService) + koreaderHandler.SetAnnotationService(annotationService) koreaderHandler.SetLibraryService(libraryService) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) conflictHandler := handlers.NewConflictHandler(queries, connManager) @@ -95,6 +100,7 @@ func main() { filtersHandler := handlers.NewFiltersHandler(queries) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) mediaHandler.SetProgressService(progressService) + mediaHandler.SetAnnotationService(annotationService) matchingHandler := handlers.NewMatchingHandler(queries, connManager) jobsHandler := handlers.NewJobsHandler(queries, worker) @@ -162,6 +168,7 @@ func main() { ConnManager: connManager, QueueProcessor: queueProcessor, ProgressService: progressService, + AnnotationService: annotationService, DeviceAuthMiddleware: deviceAuthMiddleware, JobsHandler: jobsHandler, LoginTracker: loginAttemptTracker, diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index a79906d..d7a86ab 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -84,6 +84,7 @@ type TestServerSetup struct { ConnManager *wsync.ConnectionManager QueueProcessor *wsync.SyncQueueProcessor ProgressService *wsync.ProgressService + AnnotationService *wsync.AnnotationService CleanupCancel context.CancelFunc QueueCtx context.Context QueueCancel context.CancelFunc @@ -455,6 +456,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { cleanupCancel := connManager.StartCleanupTask() progressService := wsync.NewProgressService(queries, connManager) + annotationService := wsync.NewAnnotationService(queries, connManager) queueProcessor := wsync.NewSyncQueueProcessor(queries) queueProcessor.SetProgressService(progressService) @@ -463,6 +465,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) koreaderHandler.SetProgressService(progressService) + koreaderHandler.SetAnnotationService(annotationService) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) conflictHandler := handlers.NewConflictHandler(queries, connManager) analyticsHandler := handlers.NewAnalyticsHandler(queries) @@ -482,6 +485,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { seriesHandler := handlers.NewSeriesHandler(queries) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) mediaHandler.SetProgressService(progressService) + mediaHandler.SetAnnotationService(annotationService) matchingHandler := handlers.NewMatchingHandler(queries, connManager) // Create conversion service for OPDS @@ -537,6 +541,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { ConnManager: connManager, QueueProcessor: queueProcessor, ProgressService: progressService, + AnnotationService: annotationService, DeviceAuthMiddleware: deviceAuthMiddleware, LoginTracker: loginAttemptTracker, } diff --git a/internal/handlers/conflicts.go b/internal/handlers/conflicts.go index 2d6095f..6b6023d 100644 --- a/internal/handlers/conflicts.go +++ b/internal/handlers/conflicts.go @@ -225,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error { return echo.NewHTTPError(http.StatusForbidden, "access denied") } - if conflict.ResolutionStatus.String != "unresolved" { + if conflict.ResolutionStatus.String == "user_resolved" { return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved") } @@ -258,6 +258,12 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error { } } + if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" { + if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil { + appliedTo["annotations"] = true + } + } + resolutionData := map[string]interface{}{ "winner": req.Winner, "applied_to": appliedTo, @@ -356,6 +362,143 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI return err } +func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error { + ctx := context.Background() + + dedupKey, _ := winnerData["dedup_key"].(string) + if dedupKey == "" { + return errors.New("missing dedup_key in winner data") + } + + switch conflictType { + case "annotation_highlight": + return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData) + case "annotation_bookmark": + return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData) + case "annotation_note": + return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData) + default: + return errors.New("unknown annotation conflict type") + } +} + +func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error { + existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{ + MediaItemID: mediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil { + return err + } + + params := database.UpdateMediaHighlightForSyncParams{ + ID: existing.ID, + SelectionText: existing.SelectionText, + StartPosition: existing.StartPosition, + EndPosition: existing.EndPosition, + Color: existing.Color, + NoteText: existing.NoteText, + PercentageStart: existing.PercentageStart, + PercentageEnd: existing.PercentageEnd, + EpubcfiStart: existing.EpubcfiStart, + EpubcfiEnd: existing.EpubcfiEnd, + ChapterReference: existing.ChapterReference, + LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true}, + DeviceSyncData: existing.DeviceSyncData, + } + + if v, ok := data["selection_text"].(string); ok { + params.SelectionText = v + } + if v, ok := data["color"].(string); ok { + params.Color = pgtype.Text{String: v, Valid: true} + } + if v, ok := data["note_text"].(string); ok { + params.NoteText = pgtype.Text{String: v, Valid: true} + } + if v, ok := data["start_position"].(string); ok { + params.StartPosition = pgtype.Text{String: v, Valid: true} + } + if v, ok := data["end_position"].(string); ok { + params.EndPosition = pgtype.Text{String: v, Valid: true} + } + + _, err = h.db.UpdateMediaHighlightForSync(ctx, params) + return err +} + +func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error { + existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{ + MediaItemID: mediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil { + return err + } + + params := database.UpdateMediaBookmarkForSyncParams{ + ID: existing.ID, + PageNumber: existing.PageNumber, + ChapterNumber: existing.ChapterNumber, + CfiPosition: existing.CfiPosition, + Title: existing.Title, + Position: existing.Position, + Notes: existing.Notes, + PercentageLocation: existing.PercentageLocation, + EpubcfiLocation: existing.EpubcfiLocation, + ChapterReference: existing.ChapterReference, + LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true}, + DeviceSyncData: existing.DeviceSyncData, + } + + if v, ok := data["title"].(string); ok { + params.Title = v + } + if v, ok := data["notes"].(string); ok { + params.Notes = pgtype.Text{String: v, Valid: true} + } + + _, err = h.db.UpdateMediaBookmarkForSync(ctx, params) + return err +} + +func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error { + existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{ + MediaItemID: mediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil { + return err + } + + params := database.UpdateMediaNoteForSyncParams{ + ID: existing.ID, + Content: existing.Content, + Position: existing.Position, + PercentageLocation: existing.PercentageLocation, + CharacterStart: existing.CharacterStart, + CharacterEnd: existing.CharacterEnd, + EpubcfiLocation: existing.EpubcfiLocation, + ChapterReference: existing.ChapterReference, + ParagraphReference: existing.ParagraphReference, + LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true}, + DeviceSyncData: existing.DeviceSyncData, + } + + if v, ok := data["content"].(string); ok { + params.Content = v + } + if v, ok := data["position"].(string); ok { + params.Position = pgtype.Text{String: v, Valid: true} + } + + _, err = h.db.UpdateMediaNoteForSync(ctx, params) + return err +} + func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string { devices, err := h.db.ListDevicesByType(context.Background(), "koreader") if err != nil { diff --git a/internal/handlers/kobo.go b/internal/handlers/kobo.go index 3f44442..d36efd4 100644 --- a/internal/handlers/kobo.go +++ b/internal/handlers/kobo.go @@ -3,6 +3,7 @@ package handlers import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" + "encoding/json" "fmt" "log" "net/http" @@ -19,6 +20,7 @@ type KoboHandler struct { db *database.Queries connManager *wsync.ConnectionManager progressSvc *wsync.ProgressService + annotationSvc *wsync.AnnotationService libraryService LibraryPathResolver } @@ -30,6 +32,10 @@ func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) { h.progressSvc = svc } +func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) { + h.annotationSvc = svc +} + func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) { h.libraryService = svc } @@ -245,9 +251,16 @@ type KoboInitResponse struct { } type KoboSyncStatus struct { - Status string `json:"Status"` - MarkupsSynced int `json:"MarkupsSynced"` - BookmarksSynced int `json:"BookmarksSynced"` + Status string `json:"Status"` + MarkupsSynced int `json:"MarkupsSynced"` + BookmarksSynced int `json:"BookmarksSynced"` + DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"` +} + +type KoboDeletedAnnotation struct { + ContentId string `json:"ContentId"` + BookmarkId string `json:"BookmarkId"` + Type string `json:"Type"` } type KoboServerSyncData struct { @@ -311,7 +324,7 @@ func (h *KoboHandler) Initialization(c *echo.Context) error { } bookmarkCount := 0 - annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ + annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{ MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}, UserID: pgUserID, }) @@ -403,6 +416,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error { markupsSynced := 0 bookmarksSynced := 0 unlinkedBooks := 0 + processedBooks := make(map[pgtype.UUID]string) for _, readingSync := range req.ReadingSync { bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID) @@ -412,6 +426,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error { } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} + processedBooks[pgMediaUUID] = readingSync.ContentId percentage := readingSync.PercentRead / 100.0 // Kobo only sends a percentage. For fixed-layout & comic formats the page @@ -465,29 +480,72 @@ func (h *KoboHandler) Markup(c *echo.Context) error { } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} + processedBooks[pgMediaUUID] = bookmarkSync.ContentId switch bookmarkSync.BookmarkType { case "annotation": if bookmarkSync.BookmarkText != "" { - 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++ + if h.annotationSvc != nil { + 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}, + }) + bookmarksSynced++ + } } case "bookmark": if bookmarkSync.BookmarkText != "" { - h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Content: bookmarkSync.BookmarkText, - Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - }) - bookmarksSynced++ + if h.annotationSvc != nil { + 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}, + }) + bookmarksSynced++ + } } case "last-read-place": if bookmarkSync.BookmarkId != "" { @@ -564,6 +622,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error { BookmarksSynced: bookmarksSynced, } + if h.annotationSvc != nil && len(processedBooks) > 0 { + cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true} + for mediaItemID, contentId := range processedBooks { + tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{ + MediaItemID: mediaItemID, + UserID: pgUserID, + DeletedAt: cutoff, + }) + for _, ts := range tombstones { + var dd map[string]interface{} + if len(ts.DeviceSyncData) > 0 { + json.Unmarshal(ts.DeviceSyncData, &dd) + } + bookmarkID, _ := dd["bookmark_id"].(string) + if bookmarkID == "" { + continue + } + response.DeletedAnnotations = append(response.DeletedAnnotations, KoboDeletedAnnotation{ + ContentId: contentId, + BookmarkId: bookmarkID, + Type: ts.AnnotationType, + }) + } + } + } + // Include unlinked books count if any if unlinkedBooks > 0 { // For now, just log it. In production, this should trigger an alert @@ -606,25 +690,67 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error { switch bookmarkSync.BookmarkType { case "annotation": if bookmarkSync.BookmarkText != "" { - 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++ + if h.annotationSvc != nil { + 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}, + }) + bookmarksSynced++ + } } case "bookmark": if bookmarkSync.BookmarkText != "" { - h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Content: bookmarkSync.BookmarkText, - Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, - }) - bookmarksSynced++ + if h.annotationSvc != nil { + 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}, + }) + bookmarksSynced++ + } } } } @@ -756,36 +882,79 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error { for _, bookmark := range syncData.Bookmarks { if bookmark.BookmarkType == "bookmark" { - h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Content: bookmark.BookmarkText, - Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, - }) - bookmarksSent++ + 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}, + }) + bookmarksSent++ + } } else if bookmark.BookmarkType == "annotation" { - 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++ + 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 { - 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++ + 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, + 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}, + }) + highlightsSent++ + } } } diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index 895ebea..a28b1d0 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -4,6 +4,7 @@ import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" "context" + "encoding/json" "fmt" "log" "net/http" @@ -19,6 +20,7 @@ type KOReaderHandler struct { connManager *wsync.ConnectionManager queue *wsync.SyncQueueProcessor progressSvc *wsync.ProgressService + annotationSvc *wsync.AnnotationService libraryService LibraryPathResolver } @@ -34,6 +36,27 @@ func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) { h.progressSvc = svc } +func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) { + h.annotationSvc = svc +} + +func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1 string) (string, string) { + if pos0 == "" || h.libraryService == nil { + return "", "" + } + mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID) + if err != nil { + return "", "" + } + epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath) + if err != nil || epubPath == "" { + return "", "" + } + startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, "", mediaItem.FormatGroup, epubPath, "") + endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "") + return startLoc.CFI, endLoc.CFI +} + func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) { h.libraryService = svc } @@ -154,9 +177,11 @@ type KOReaderProgressData struct { } type KOReaderAnnotations struct { - Highlights []KOReaderHighlight `json:"highlights,omitempty"` - Notes []KOReaderNote `json:"notes,omitempty"` - Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` + Highlights []KOReaderHighlight `json:"highlights,omitempty"` + Notes []KOReaderNote `json:"notes,omitempty"` + Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` + DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"` + DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"` } type KOReaderLibraryResponse struct { @@ -396,6 +421,7 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database. if synced { booksEnqueued++ } + h.processBookAnnotations(c.Request().Context(), device.ID, userID, mediaItemID, book) bookResults = append(bookResults, KOReaderBookSyncResult{ SHA256: book.SHA256, BookUUID: uuid.UUID(mediaItemID.Bytes).String(), @@ -441,6 +467,102 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp return h.queue.EnqueueProgress(update) } +func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, userID, mediaItemID pgtype.UUID, book KOReaderBookProgress) { + if h.annotationSvc == nil { + return + } + + for _, hl := range book.Highlights { + startPos := hl.Pos0 + endPos := hl.Pos1 + epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos) + + pctStart := 0.0 + if hl.Percentage != nil { + pctStart = *hl.Percentage + } + + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": hl.Datetime, + "pos0": hl.Pos0, + "pos1": hl.Pos1, + "page": hl.Page, + }) + + h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ + MediaItemID: mediaItemID, + UserID: userID, + SelectionText: hl.Text, + StartPosition: startPos, + EndPosition: endPos, + Color: hl.Color, + NoteText: hl.Notes, + PercentageStart: pctStart, + EpubcfiStart: epubcfiStart, + EpubcfiEnd: epubcfiEnd, + Source: "koreader", + DeviceSyncData: deviceData, + }) + } + + for _, note := range book.Notes { + startPos := note.Pos0 + endPos := note.Pos1 + epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos) + + pctStart := 0.0 + if note.Percentage != nil { + pctStart = *note.Percentage + } + + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": note.Datetime, + "pos0": note.Pos0, + "pos1": note.Pos1, + "page": note.Page, + }) + + h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ + MediaItemID: mediaItemID, + UserID: userID, + SelectionText: note.Text, + StartPosition: startPos, + EndPosition: endPos, + NoteText: note.Notes, + PercentageStart: pctStart, + EpubcfiStart: epubcfiStart, + EpubcfiEnd: epubcfiEnd, + Source: "koreader", + DeviceSyncData: deviceData, + }) + } + + for _, bookmark := range book.Bookmarks { + position := "" + if bookmark.Pos0 != "" { + position = bookmark.Pos0 + } else if bookmark.Page > 0 { + position = fmt.Sprintf("page:%d", bookmark.Page) + } + + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": bookmark.Datetime, + "pos0": bookmark.Pos0, + "page": bookmark.Page, + }) + + h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{ + MediaItemID: mediaItemID, + UserID: userID, + Title: bookmark.Text, + Position: position, + ChapterNumber: int32(bookmark.Chapter), + Source: "koreader", + DeviceSyncData: deviceData, + }) + } +} + func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error { ctx := c.Request().Context() @@ -522,7 +644,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype } _, err := h.progressSvc.SaveProgress(ctx, saveReq) - return err + if err != nil { + return err + } + h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book) + return nil } _, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{ @@ -558,6 +684,8 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype }, ) + h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book) + return nil } @@ -657,7 +785,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { progressData.TotalPages = &progress } - annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ + annotations, err := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{ MediaItemID: pgBookUUID, UserID: pgUserID, }) @@ -670,13 +798,29 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { for _, ann := range annotations { if ann.AnnotationType == "highlight" { - annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{ + pos0 := ann.StartPosition.String + pos1 := ann.EndPosition.String + if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" { + if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" { + pos0 = converted + } + } + if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" { + if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" { + pos1 = converted + } + } + highlight := KOReaderHighlight{ Text: ann.SelectionText, - Pos0: ann.StartPosition.String, - Pos1: ann.EndPosition.String, + Pos0: pos0, + Pos1: pos1, Color: ann.Color.String, Datetime: ann.CreatedAt.Time.Format(time.RFC3339), - }) + } + if ann.NoteText.Valid && ann.NoteText.String != "" { + highlight.Notes = ann.NoteText.String + } + annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight) } else if ann.AnnotationType == "note" { annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{ Text: ann.SelectionText, @@ -686,6 +830,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { } } + bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{ + MediaItemID: pgBookUUID, + UserID: pgUserID, + }) + for _, bm := range bookmarks { + pos0 := bm.Position.String + if pos0 == "" && bm.CfiPosition.Valid { + pos0 = bm.CfiPosition.String + } + koreaderBookmark := KOReaderBookmark{ + Text: bm.Title, + Pos0: pos0, + Pos1: pos0, + Datetime: bm.CreatedAt.Time.Format(time.RFC3339), + } + if bm.Notes.Valid && bm.Notes.String != "" { + koreaderBookmark.Notes = bm.Notes.String + } + if bm.ChapterNumber.Valid { + koreaderBookmark.Chapter = int(bm.ChapterNumber.Int32) + } + annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark) + } + + cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true} + tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{ + MediaItemID: pgBookUUID, + UserID: pgUserID, + DeletedAt: cutoff, + }) + for _, ts := range tombstones { + var dd map[string]interface{} + if len(ts.DeviceSyncData) > 0 { + json.Unmarshal(ts.DeviceSyncData, &dd) + } + if dd == nil { + dd = map[string]interface{}{} + } + dd["dedup_key"] = ts.DedupKey.String + if ts.AnnotationType == "highlight" { + annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd) + } else if ts.AnnotationType == "bookmark" { + annotationsResponse.DeletedBookmarks = append(annotationsResponse.DeletedBookmarks, dd) + } + } + lastSync := "never" if progress.LastSyncTimestamp.Valid { lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339) @@ -737,6 +927,21 @@ func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem databa } } +func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string) string { + if h.libraryService == nil || epubcfi == "" { + return "" + } + epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) + if err != nil || epubPath == "" { + return "" + } + loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, "", mediaItem.FormatGroup, epubPath, "") + if loc.Position != "" && loc.Position != epubcfi { + return loc.Position + } + return "" +} + func (h *KOReaderHandler) GetLibrary(c *echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes @@ -776,7 +981,7 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error { } } - annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ + annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{ MediaItemID: pgItemUUID, UserID: pgUserID, }) @@ -876,15 +1081,36 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { position = fmt.Sprintf("page:%d", bookmark.Page) } - _, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{ - MediaItemID: mediaItemID, - UserID: pgUserID, - Content: bookmark.Text, - Position: pgtype.Text{String: position, Valid: position != ""}, - }) + if h.annotationSvc != nil { + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": bookmark.Datetime, + "pos0": bookmark.Pos0, + "page": bookmark.Page, + }) - if err == nil { - bookmarksSynced++ + result, err := h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{ + MediaItemID: mediaItemID, + UserID: pgUserID, + Title: bookmark.Text, + Position: position, + ChapterNumber: int32(bookmark.Chapter), + Source: "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++ + } } } @@ -906,15 +1132,35 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { position = fmt.Sprintf("page:%d", note.Page) } - _, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{ - MediaItemID: mediaItemID, - UserID: pgUserID, - Content: note.Notes, - Position: pgtype.Text{String: position, Valid: position != ""}, - }) + if h.annotationSvc != nil { + deviceData, _ := json.Marshal(map[string]interface{}{ + "datetime": note.Datetime, + "pos0": note.Pos0, + "page": note.Page, + }) - 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++ + } + } 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++ + } } } @@ -941,17 +1187,51 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { color = highlight.Color } - _, 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 h.annotationSvc != nil { + epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1) - if err == nil { - highlightsSynced++ + pctStart := 0.0 + if highlight.Percentage != nil { + pctStart = *highlight.Percentage + } + + 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++ + } } } diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 7f4b007..9688873 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -19,6 +19,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -136,6 +137,7 @@ type MediaHandler struct { libraryService *services.LibraryService searchService *services.SearchService progressSvc *wsync.ProgressService + annotationSvc *wsync.AnnotationService } func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler { @@ -154,6 +156,10 @@ func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) { mh.progressSvc = svc } +func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) { + mh.annotationSvc = svc +} + func (h *MediaHandler) DownloadBook(c *echo.Context) error { bookUUID, err := uuid.Parse(c.Param("uuid")) if err != nil { @@ -1388,14 +1394,31 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": 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()}) + 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()}) + } } return c.JSON(http.StatusCreated, note) @@ -1456,7 +1479,11 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) } - err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}) + if mh.annotationSvc != nil { + err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}) + } else { + err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}) + } if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } @@ -1525,9 +1552,29 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error { color = req.Color } + 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, + Color: color, + 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: pgtype.UUID{Bytes: mediaUUID, Valid: true}, - UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + MediaItemID: pgMediaID, + UserID: pgUserID, SelectionText: req.SelectionText, StartPosition: pgtype.Text{String: req.StartPosition, Valid: true}, EndPosition: pgtype.Text{String: req.EndPosition, Valid: true}, @@ -1613,7 +1660,16 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"}) } - err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true}) + pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true} + + if mh.annotationSvc != nil { + if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.NoContent(http.StatusNoContent) + } + + err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } diff --git a/internal/router/router.go b/internal/router/router.go index 8f25e62..9e2f20d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -62,6 +62,7 @@ type Config struct { ConnManager *sync.ConnectionManager QueueProcessor *sync.SyncQueueProcessor ProgressService *sync.ProgressService + AnnotationService *sync.AnnotationService DeviceAuthMiddleware *middleware.DeviceAuthMiddleware LoginTracker *ratelimit.LoginAttemptTracker ScannerHandler *handlers.Handler diff --git a/internal/router/sync.go b/internal/router/sync.go index 4731fa6..406e453 100644 --- a/internal/router/sync.go +++ b/internal/router/sync.go @@ -33,6 +33,7 @@ func registerSyncRoutes(cfg *Config) { // API clients can use Authorization header: Authorization: Bearer {token} koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager) koboHandler.SetProgressService(cfg.ProgressService) + koboHandler.SetAnnotationService(cfg.AnnotationService) koboHandler.SetLibraryService(cfg.LibraryService) koboSync := e.Group("/api/sync/kobo/:token") koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))