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))