package handlers import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" "encoding/json" "fmt" "net/http" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" ) type KOReaderHandler struct { db *database.Queries connManager *wsync.ConnectionManager queue *wsync.SyncQueueProcessor } func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler { return &KOReaderHandler{db: db, connManager: connManager, queue: queue} } type KOReaderProgressRequest struct { LibraryID *string `json:"library_id,omitempty"` Books []KOReaderBookProgress `json:"books" validate:"required"` SyncMode string `json:"sync_mode,omitempty"` } type KOReaderBookProgress struct { UUID string `json:"uuid,omitempty"` SHA256 string `json:"sha256,omitempty"` Title string `json:"title,omitempty"` Authors []string `json:"authors,omitempty"` Progress float64 `json:"progress"` Percentage float64 `json:"percentage"` LastRead string `json:"last_read,omitempty"` FilePath string `json:"file_path,omitempty"` DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"` Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` Highlights []KOReaderHighlight `json:"highlights,omitempty"` Notes []KOReaderNote `json:"notes,omitempty"` Chapter *int `json:"chapter,omitempty"` Character *int64 `json:"character,omitempty"` Epubcfi *string `json:"epubcfi,omitempty"` Page *int `json:"page,omitempty"` TotalPages *int `json:"total_pages,omitempty"` } type KOReaderDeviceInfo struct { KOReaderVersion string `json:"koreader_version,omitempty"` DeviceModel string `json:"device_model,omitempty"` } type KOReaderBookmark struct { Chapter int `json:"chapter,omitempty"` Datetime string `json:"datetime,omitempty"` Notes string `json:"notes,omitempty"` Pos0 string `json:"pos0,omitempty"` Pos1 string `json:"pos1,omitempty"` Page int `json:"page,omitempty"` Text string `json:"text,omitempty"` Type string `json:"type,omitempty"` Percentage *float64 `json:"percentage,omitempty"` BookSHA256 string `json:"book_sha256,omitempty"` } type KOReaderHighlight struct { Chapter int `json:"chapter,omitempty"` Datetime string `json:"datetime,omitempty"` Notes string `json:"notes,omitempty"` Pos0 string `json:"pos0,omitempty"` Pos1 string `json:"pos1,omitempty"` Page int `json:"page,omitempty"` Text string `json:"text,omitempty"` Type string `json:"type,omitempty"` Color string `json:"color,omitempty"` Percentage *float64 `json:"percentage,omitempty"` BookSHA256 string `json:"book_sha256,omitempty"` } type KOReaderNote struct { Chapter int `json:"chapter,omitempty"` Datetime string `json:"datetime,omitempty"` Notes string `json:"notes,omitempty"` Pos0 string `json:"pos0,omitempty"` Pos1 string `json:"pos1,omitempty"` Page int `json:"page,omitempty"` Text string `json:"text,omitempty"` Type string `json:"type,omitempty"` Percentage *float64 `json:"percentage,omitempty"` BookSHA256 string `json:"book_sha256,omitempty"` } type KOReaderSyncResponse struct { SyncStatus string `json:"sync_status"` BooksSynced int `json:"books_synced"` Conflicts []KOReaderConflict `json:"conflicts,omitempty"` Timestamp string `json:"timestamp"` DeviceUpdated bool `json:"device_updated"` } type KOReaderConflict struct { BookUUID string `json:"book_uuid"` ConflictType string `json:"conflict_type"` DeviceProgress float64 `json:"device_progress"` ServerProgress float64 `json:"server_progress"` Resolution string `json:"resolution"` } type KOReaderMetadata struct { UUID string `json:"uuid"` Title string `json:"title"` Authors []string `json:"authors"` Progress KOReaderProgressData `json:"progress"` Annotations KOReaderAnnotations `json:"annotations"` LastSync string `json:"last_sync"` } type KOReaderProgressData struct { Percentage float64 `json:"percentage"` Character *int64 `json:"character,omitempty"` Epubcfi *string `json:"epubcfi,omitempty"` Chapter *int `json:"chapter,omitempty"` ChapterProgress *float64 `json:"chapter_progress,omitempty"` Page *int `json:"page,omitempty"` TotalPages *int `json:"total_pages,omitempty"` } type KOReaderAnnotations struct { Highlights []KOReaderHighlight `json:"highlights,omitempty"` Notes []KOReaderNote `json:"notes,omitempty"` Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` } type KOReaderLibraryResponse struct { LibrarySync []KOReaderLibraryBook `json:"library_sync"` TotalBooks int `json:"total_books"` LastSync string `json:"last_sync"` } type KOReaderLibraryBook struct { UUID string `json:"uuid"` Title string `json:"title"` Author string `json:"author"` ContentType string `json:"content_type"` PercentRead float64 `json:"percent_read"` PagesRemaining *int `json:"pages_remaining,omitempty"` BookmarkCount int `json:"bookmark_count"` LastModified string `json:"last_modified"` } func (h *KOReaderHandler) SyncProgress(c echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes var req KOReaderProgressRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "invalid request format", "details": err.Error(), }) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": err.Error(), }) } pgUserID := pgtype.UUID{Bytes: userID, Valid: true} syncMode := req.SyncMode if syncMode == "" { syncMode = "immediate" } if syncMode == "checkpoint" { return h.handleCheckpointSync(c, device, pgUserID, req) } booksSynced := 0 conflicts := []KOReaderConflict{} for _, book := range req.Books { mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book) if !mediaItemID.Valid { continue } err := h.updateProgressForBook(c, pgUserID, mediaItemID, book) if err == nil { booksSynced++ } } _, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "error": "failed to update device timestamp", }) } if syncMode == "immediate" { return c.JSON(http.StatusAccepted, KOReaderSyncResponse{ SyncStatus: "accepted", BooksSynced: booksSynced, Conflicts: conflicts, Timestamp: time.Now().Format(time.RFC3339), DeviceUpdated: true, }) } return c.JSON(http.StatusOK, KOReaderSyncResponse{ SyncStatus: "completed", BooksSynced: booksSynced, Conflicts: conflicts, Timestamp: time.Now().Format(time.RFC3339), DeviceUpdated: true, }) } func (h *KOReaderHandler) resolveBookToMediaItem(c echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) { ctx := c.Request().Context() // Priority 1: UUID provided (highest confidence - 1.0) if book.UUID != "" { mediaUUID, err := uuid.Parse(book.UUID) if err == nil { mediaItem, err := h.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaUUID, Valid: true}) if err == nil { // Create device file alias if FilePath is also provided if book.FilePath != "" { h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book) } return mediaItem.ID, 1.0 } } } // Priority 2: SHA-256 provided (medium confidence - 0.9) if book.SHA256 != "" && len(book.SHA256) == 64 { mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: book.SHA256, Valid: true}) if err == nil { // Create device file alias if FilePath is provided if book.FilePath != "" { h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book) } return mediaItem.ID, 0.9 } } // Priority 3: FilePath provided (check existing alias or create new - confidence 0.7) if book.FilePath != "" { // Check if alias already exists alias, err := h.db.GetDeviceFileAlias(ctx, database.GetDeviceFileAliasParams{ DeviceID: deviceID, FilePath: book.FilePath, }) if err == nil { return alias.MediaItemID, alias.ConfidenceScore.Float64 } // Try to find by file path mediaItem, err := h.db.GetMediaItemByFilePath(ctx, book.FilePath) if err == nil { // Create new alias confidence := 0.7 h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book) return mediaItem.ID, confidence } } // Priority 4: Title + Author match (lowest confidence - 0.5) if book.Title != "" { mediaItems, err := h.db.ListMediaItems(ctx, database.ListMediaItemsParams{ Limit: 100, Offset: 0, }) if err == nil { for _, mi := range mediaItems { if mi.Title == book.Title { // Check author match if provided if len(book.Authors) > 0 && mi.Author.Valid { if mi.Author.String == book.Authors[0] { // Create device file alias if FilePath is provided if book.FilePath != "" { h.createDeviceFileAlias(c, deviceID, mi.ID, book) } return mi.ID, 0.5 } } else { // Title only match if book.FilePath != "" { h.createDeviceFileAlias(c, deviceID, mi.ID, book) } return mi.ID, 0.4 } } } } } return pgtype.UUID{}, 0.0 } func (h *KOReaderHandler) createDeviceFileAlias(c echo.Context, deviceID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) { ctx := c.Request().Context() if book.FilePath == "" { return } sha256 := book.SHA256 if sha256 == "" { // Try to get SHA256 from media item mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID) if err == nil && mediaItem.FileSha256.Valid { sha256 = mediaItem.FileSha256.String } } confidence := 0.7 // Check if alias already exists _, err := h.db.GetDeviceFileAlias(ctx, database.GetDeviceFileAliasParams{ DeviceID: deviceID, FilePath: book.FilePath, }) if err != nil { // Create new alias _, _ = h.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{ MediaItemID: mediaItemID, DeviceID: deviceID, FilePath: book.FilePath, FileSha256: pgtype.Text{String: sha256, Valid: sha256 != ""}, ConfidenceScore: pgtype.Float8{Float64: confidence, Valid: true}, }) } } func (h *KOReaderHandler) handleCheckpointSync(c echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error { booksEnqueued := 0 for _, book := range req.Books { mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book) if !mediaItemID.Valid { continue } err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book) if err == nil { booksEnqueued++ } } _, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "error": "failed to update device timestamp", }) } return c.JSON(http.StatusAccepted, map[string]interface{}{ "sync_status": "checkpoint_enqueued", "books_enqueued": booksEnqueued, "message": "Sync will be processed in the background", "timestamp": time.Now().Format(time.RFC3339), }) } func (h *KOReaderHandler) enqueueProgressForBook(c echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error { if h.queue == nil { return fmt.Errorf("sync queue not available") } update := &wsync.ProgressUpdate{ DeviceID: deviceID, MediaItemID: mediaItemID, UserID: userID, Percentage: book.Percentage, Epubcfi: book.Epubcfi, Chapter: book.Chapter, Character: book.Character, Page: book.Page, TotalPages: book.TotalPages, Source: "koreader", SyncMode: "checkpoint", } return h.queue.EnqueueProgress(update) } func (h *KOReaderHandler) updateProgressForBook(c echo.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error { ctx := c.Request().Context() existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{ MediaItemID: mediaItemID, UserID: userID, }) if err != nil && err != pgx.ErrNoRows { return err } hasExistingProgress := err != pgx.ErrNoRows conflictDetected := false if hasExistingProgress && existingProgress.LastSyncSource.Valid { if existingProgress.LastSyncSource.String != "koreader" && existingProgress.LastSyncTimestamp.Valid { timeDiff := time.Since(existingProgress.LastSyncTimestamp.Time) if timeDiff < 5*time.Minute { percentageDiff := book.Percentage - existingProgress.Percentage.Float64 if percentageDiff < 0 { percentageDiff = -percentageDiff } if percentageDiff > 0.01 { conflictDetected = true } } } } var epubcfi pgtype.Text var chapter pgtype.Int4 var characterOffset pgtype.Int8 var currentPage pgtype.Int4 var totalPages pgtype.Int4 if book.Epubcfi != nil { epubcfi = pgtype.Text{String: *book.Epubcfi, Valid: true} } if book.Chapter != nil { chapter = pgtype.Int4{Int32: int32(*book.Chapter), Valid: true} } if book.Character != nil { characterOffset = pgtype.Int8{Int64: *book.Character, Valid: true} } if book.Page != nil { currentPage = pgtype.Int4{Int32: int32(*book.Page), Valid: true} } if book.TotalPages != nil { totalPages = pgtype.Int4{Int32: int32(*book.TotalPages), Valid: true} } _, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{ MediaItemID: mediaItemID, UserID: userID, Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true}, Epubcfi: epubcfi, Chapter: chapter, ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true}, CharacterOffset: characterOffset, CurrentPage: currentPage, TotalPages: totalPages, LastSyncDevice: pgtype.Text{String: "koreader", Valid: true}, LastSyncSource: pgtype.Text{String: "koreader", Valid: true}, ViewportY: pgtype.Float8{}, ScrollPositionX: pgtype.Float8{}, ScrollPositionY: pgtype.Float8{}, PanelNumber: pgtype.Int4{}, ReadingMode: pgtype.Text{}, ZoomLevel: pgtype.Float8{}, }) if err != nil { return err } if conflictDetected { koreaderData := map[string]interface{}{ "source": "koreader", "timestamp": time.Now(), "data": map[string]interface{}{ "percentage": book.Percentage, }, } if book.Epubcfi != nil { koreaderData["data"].(map[string]interface{})["epubcfi"] = *book.Epubcfi } if book.Chapter != nil { koreaderData["data"].(map[string]interface{})["chapter"] = *book.Chapter } if book.Character != nil { koreaderData["data"].(map[string]interface{})["character"] = *book.Character } if book.Page != nil { koreaderData["data"].(map[string]interface{})["page"] = *book.Page } if book.TotalPages != nil { koreaderData["data"].(map[string]interface{})["total_pages"] = *book.TotalPages } existingData := map[string]interface{}{ "source": existingProgress.LastSyncSource.String, "timestamp": existingProgress.LastSyncTimestamp.Time, "data": map[string]interface{}{ "percentage": existingProgress.Percentage.Float64, }, } if existingProgress.Epubcfi.Valid { existingData["data"].(map[string]interface{})["epubcfi"] = existingProgress.Epubcfi.String } if existingProgress.Chapter.Valid { existingData["data"].(map[string]interface{})["chapter"] = existingProgress.Chapter.Int32 } if existingProgress.CharacterOffset.Valid { existingData["data"].(map[string]interface{})["character"] = existingProgress.CharacterOffset.Int64 } if existingProgress.CurrentPage.Valid { existingData["data"].(map[string]interface{})["page"] = existingProgress.CurrentPage.Int32 } if existingProgress.TotalPages.Valid { existingData["data"].(map[string]interface{})["total_pages"] = existingProgress.TotalPages.Int32 } conflictData := map[string]interface{}{ "koreader": koreaderData, "existing": existingData, } conflictDataJSON, _ := json.Marshal(conflictData) _, err := h.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{ MediaItemID: mediaItemID, UserID: userID, ConflictType: "progress", ConflictData: conflictDataJSON, }) if err == nil { h.connManager.BroadcastConflictNotification( mediaItemID.Bytes, "detection", "", ) } } deviceInfo := book.DeviceInfo if deviceInfo.DeviceModel == "" { deviceInfo.DeviceModel = "KOReader Device" } h.connManager.BroadcastProgressUpdate( uuid.UUID(mediaItemID.Bytes), book.Percentage, wsync.SourceDevice{ ID: uuid.UUID(userID.Bytes).String(), Name: deviceInfo.DeviceModel, Type: "koreader", }, ) _, err = h.db.UpdateDeviceLastSync(ctx, pgtype.UUID{Bytes: [16]byte{}, Valid: false}) return err } func (h *KOReaderHandler) GetMetadata(c echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes bookUUID, err := uuid.Parse(c.Param("uuid")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "invalid book UUID", }) } pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true} pgUserID := pgtype.UUID{Bytes: userID, Valid: true} mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{ "error": "book not found", }) } progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{ MediaItemID: pgBookUUID, UserID: pgUserID, }) if err != nil { return c.JSON(http.StatusOK, map[string]interface{}{ "uuid": bookUUID, "title": mediaItem.Title, "author": mediaItem.Author, "progress": nil, "annotations": map[string][]interface{}{}, }) } progressData := KOReaderProgressData{ Percentage: progress.Percentage.Float64, } if progress.Epubcfi.Valid { progressData.Epubcfi = &progress.Epubcfi.String } if progress.Chapter.Valid { ch := int(progress.Chapter.Int32) progressData.Chapter = &ch } if progress.ChapterProgress.Valid { cp := progress.ChapterProgress.Float64 progressData.ChapterProgress = &cp } if progress.CharacterOffset.Valid { co := int64(progress.CharacterOffset.Int64) progressData.Character = &co } if progress.CurrentPage.Valid { cp := int(progress.CurrentPage.Int32) progressData.Page = &cp } if progress.TotalPages.Valid { tp := int(progress.TotalPages.Int32) progressData.TotalPages = &tp } annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ MediaItemID: pgBookUUID, UserID: pgUserID, }) annotationsResponse := KOReaderAnnotations{ Highlights: []KOReaderHighlight{}, Notes: []KOReaderNote{}, Bookmarks: []KOReaderBookmark{}, } for _, ann := range annotations { if ann.AnnotationType == "highlight" { annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{ Text: ann.SelectionText, Pos0: ann.StartPosition.String, Pos1: ann.EndPosition.String, Color: ann.Color.String, Datetime: ann.CreatedAt.Time.Format(time.RFC3339), }) } else if ann.AnnotationType == "note" { annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{ Text: ann.SelectionText, Pos0: ann.StartPosition.String, Datetime: ann.CreatedAt.Time.Format(time.RFC3339), }) } } lastSync := "never" if progress.LastSyncTimestamp.Valid { lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339) } metadata := KOReaderMetadata{ UUID: bookUUID.String(), Title: mediaItem.Title, Authors: []string{mediaItem.Author.String}, Progress: progressData, Annotations: annotationsResponse, LastSync: lastSync, } return c.JSON(http.StatusOK, metadata) } func (h *KOReaderHandler) GetLibrary(c echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes pgUserID := pgtype.UUID{Bytes: userID, Valid: true} mediaItems, err := h.db.GetUserMediaItemsForSync(c.Request().Context(), pgUserID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "error": "failed to fetch library", }) } libraryBooks := []KOReaderLibraryBook{} for _, item := range mediaItems { pgItemUUID := pgtype.UUID{Bytes: item.ID.Bytes, Valid: true} progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{ MediaItemID: pgItemUUID, UserID: pgUserID, }) percentRead := 0.0 var pagesRemaining *int bookmarkCount := 0 lastModified := time.Now().Format(time.RFC3339) if err == nil { percentRead = progress.Percentage.Float64 if progress.TotalPages.Valid && progress.CurrentPage.Valid { remaining := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32) pagesRemaining = &remaining } if progress.LastReadAt.Valid { lastModified = progress.LastReadAt.Time.Format(time.RFC3339) } } annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ MediaItemID: pgItemUUID, UserID: pgUserID, }) bookmarkCount = len(annotations) libraryBooks = append(libraryBooks, KOReaderLibraryBook{ UUID: uuid.UUID(item.ID.Bytes).String(), Title: item.Title, Author: item.Author.String, ContentType: "6", PercentRead: percentRead * 100, PagesRemaining: pagesRemaining, BookmarkCount: bookmarkCount, LastModified: lastModified, }) } return c.JSON(http.StatusOK, KOReaderLibraryResponse{ LibrarySync: libraryBooks, TotalBooks: len(libraryBooks), LastSync: time.Now().Format(time.RFC3339), }) } func (h *KOReaderHandler) SyncBookmarks(c echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes var req struct { BookUUID string `json:"book_uuid,omitempty"` BookSHA256 string `json:"book_sha256,omitempty"` Bookmarks []KOReaderBookmark `json:"bookmarks"` Notes []KOReaderNote `json:"notes"` Highlights []KOReaderHighlight `json:"highlights"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "invalid request format", }) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": err.Error(), }) } ctx := c.Request().Context() pgUserID := pgtype.UUID{Bytes: userID, Valid: true} // Resolve media item ID using priority matching var pgBookUUID pgtype.UUID if req.BookUUID != "" { // Use UUID directly bookUUID, err := uuid.Parse(req.BookUUID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "invalid book UUID", }) } pgBookUUID = pgtype.UUID{Bytes: bookUUID, Valid: true} } else if req.BookSHA256 != "" && len(req.BookSHA256) == 64 { // Use SHA-256 to find book mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: req.BookSHA256, Valid: true}) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{ "error": "book not found by SHA-256", }) } pgBookUUID = mediaItem.ID } else { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "either book_uuid or book_sha256 is required", }) } bookmarksSynced := 0 notesSynced := 0 highlightsSynced := 0 for _, bookmark := range req.Bookmarks { mediaItemID := pgBookUUID // If bookmark has its own SHA-256, use it for matching if bookmark.BookSHA256 != "" && len(bookmark.BookSHA256) == 64 { mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: bookmark.BookSHA256, Valid: true}) if err == nil { mediaItemID = mediaItem.ID } } position := "" if bookmark.Pos0 != "" { position = bookmark.Pos0 } else if bookmark.Page > 0 { 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 err == nil { bookmarksSynced++ } } for _, note := range req.Notes { mediaItemID := pgBookUUID // If note has its own SHA-256, use it for matching if note.BookSHA256 != "" && len(note.BookSHA256) == 64 { mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: note.BookSHA256, Valid: true}) if err == nil { mediaItemID = mediaItem.ID } } position := "" if note.Pos0 != "" { position = note.Pos0 } else if note.Page > 0 { 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 err == nil { notesSynced++ } } for _, highlight := range req.Highlights { mediaItemID := pgBookUUID // If highlight has its own SHA-256, use it for matching if highlight.BookSHA256 != "" && len(highlight.BookSHA256) == 64 { mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: highlight.BookSHA256, Valid: true}) if err == nil { mediaItemID = mediaItem.ID } } startPos := highlight.Pos0 endPos := highlight.Pos1 if startPos == "" && highlight.Page > 0 { startPos = fmt.Sprintf("page:%d", highlight.Page) endPos = startPos } color := "#ffff00" if highlight.Color != "" { 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 err == nil { highlightsSynced++ } } return c.JSON(http.StatusOK, map[string]interface{}{ "sync_status": "completed", "bookmarks_synced": bookmarksSynced, "notes_synced": notesSynced, "highlights_synced": highlightsSynced, "total_synced": bookmarksSynced + notesSynced + highlightsSynced, "timestamp": time.Now().Format(time.RFC3339), }) }