package handlers import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" "context" "fmt" "log" "net/http" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) type KOReaderHandler struct { db *database.Queries connManager *wsync.ConnectionManager queue *wsync.SyncQueueProcessor progressSvc *wsync.ProgressService libraryService LibraryPathResolver } type LibraryPathResolver interface { ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) } func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler { return &KOReaderHandler{db: db, connManager: connManager, queue: queue} } func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) { h.progressSvc = svc } func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) { h.libraryService = svc } 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"` ContextText *string `json:"context_text,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"` BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"` Conflicts []KOReaderConflict `json:"conflicts,omitempty"` Timestamp string `json:"timestamp"` DeviceUpdated bool `json:"device_updated"` } type KOReaderBookSyncResult struct { SHA256 string `json:"sha256"` BookUUID string `json:"book_uuid"` Synced bool `json:"synced"` } 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"` KoreaderXPointer *string `json:"koreader_xpointer,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{} bookResults := []KOReaderBookSyncResult{} for _, book := range req.Books { mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book) if !mediaItemID.Valid { bookResults = append(bookResults, KOReaderBookSyncResult{ SHA256: book.SHA256, Synced: false, }) continue } err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book) synced := err == nil if synced { booksSynced++ } bookResults = append(bookResults, KOReaderBookSyncResult{ SHA256: book.SHA256, BookUUID: uuid.UUID(mediaItemID.Bytes).String(), Synced: synced, }) } _, 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, BookResults: bookResults, Conflicts: conflicts, Timestamp: time.Now().Format(time.RFC3339), DeviceUpdated: true, }) } return c.JSON(http.StatusOK, KOReaderSyncResponse{ SyncStatus: "completed", BooksSynced: booksSynced, BookResults: bookResults, 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 (search any library) mediaItem, err := h.db.GetMediaItemByFilePathAnyLibrary(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 bookResults := []KOReaderBookSyncResult{} for _, book := range req.Books { mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book) if !mediaItemID.Valid { bookResults = append(bookResults, KOReaderBookSyncResult{ SHA256: book.SHA256, Synced: false, }) continue } err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book) synced := err == nil if synced { booksEnqueued++ } bookResults = append(bookResults, KOReaderBookSyncResult{ SHA256: book.SHA256, BookUUID: uuid.UUID(mediaItemID.Bytes).String(), Synced: synced, }) } _, 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, KOReaderSyncResponse{ SyncStatus: "checkpoint_enqueued", BooksSynced: booksEnqueued, BookResults: bookResults, 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, ContextText: book.ContextText, 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, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error { ctx := c.Request().Context() deviceInfo := book.DeviceInfo deviceModel := deviceInfo.DeviceModel if deviceModel == "" { deviceModel = "KOReader Device" } if h.progressSvc != nil { epubcfi := book.Epubcfi if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) { log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi) mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID) if err != nil { log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err) } else if h.libraryService == nil { log.Printf("Bookhoard: CRE→CFI libraryService is nil, skipping conversion") } else { epubPath, resolveErr := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath) if resolveErr != nil { log.Printf("Bookhoard: CRE→CFI failed to resolve media path: %v", resolveErr) } else if epubPath == "" { log.Printf("Bookhoard: CRE→CFI resolved empty epub path for %s", mediaItem.FilePath) } else { log.Printf("Bookhoard: CRE→CFI resolved epub path: %s", epubPath) converter := wsync.NewCFIConverter(epubPath) pct := 0.0 if book.Percentage >= 0 { pct = book.Percentage } contextText := "" if book.ContextText != nil { contextText = *book.ContextText } result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText) if convErr != nil { log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr) } else if result != nil { if result.EPUBCFI != "" { convertedCFI := result.EPUBCFI epubcfi = &convertedCFI log.Printf("Bookhoard: CRE→CFI converted to epubcfi: %s", convertedCFI) } else if result.Href != "" { convertedHref := result.Href epubcfi = &convertedHref log.Printf("Bookhoard: CRE→CFI converted to href: %s", convertedHref) } else { log.Printf("Bookhoard: CRE→CFI conversion: %s precision for %s", result.Precision, *epubcfi) } } } } } saveReq := wsync.SaveProgressRequest{ MediaItemID: mediaItemID, UserID: userID, Source: "koreader", DeviceID: deviceID, Percentage: &book.Percentage, Epubcfi: epubcfi, ContextText: book.ContextText, Chapter: book.Chapter, CharacterOffset: book.Character, CurrentPage: book.Page, TotalPages: book.TotalPages, DeviceType: "koreader", DeviceName: deviceModel, Broadcast: true, } _, err := h.progressSvc.SaveProgress(ctx, saveReq) return err } _, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{ MediaItemID: mediaItemID, UserID: userID, Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true}, Epubcfi: textPtrToPgText(book.Epubcfi), Chapter: intPtrToPgInt4(book.Chapter), ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true}, CharacterOffset: int64PtrToPgInt8(book.Character), CurrentPage: intPtrToPgInt4(book.Page), TotalPages: intPtrToPgInt4(book.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 } h.connManager.BroadcastProgressUpdate( mediaItemID.Bytes, book.Percentage, wsync.SourceDevice{ ID: uuid.UUID(deviceID.Bytes).String(), Name: deviceModel, Type: "koreader", }, ) return nil } func textPtrToPgText(s *string) pgtype.Text { if s != nil { return pgtype.Text{String: *s, Valid: true} } return pgtype.Text{} } func intPtrToPgInt4(i *int) pgtype.Int4 { if i != nil { return pgtype.Int4{Int32: int32(*i), Valid: true} } return pgtype.Int4{} } func int64PtrToPgInt8(i *int64) pgtype.Int8 { if i != nil { return pgtype.Int8{Int64: *i, Valid: true} } return pgtype.Int8{} } 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.Epubcfi.Valid && wsync.IsStandardEPUBCFI(progress.Epubcfi.String) { h.convertCFIToXPointer(c, mediaItem, progress, &progressData) } if progress.Chapter.Valid { progress := int(progress.Chapter.Int32) progressData.Chapter = &progress } if progress.ChapterProgress.Valid { progress := progress.ChapterProgress.Float64 progressData.ChapterProgress = &progress } if progress.CharacterOffset.Valid { progress := progress.CharacterOffset.Int64 progressData.Character = &progress } if progress.CurrentPage.Valid { progress := int(progress.CurrentPage.Int32) progressData.Page = &progress } if progress.TotalPages.Valid { progress := int(progress.TotalPages.Int32) progressData.TotalPages = &progress } 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) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) { if h.libraryService == nil { log.Printf("Bookhoard: CFI→CRE libraryService is nil, skipping reverse conversion") return } epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) if err != nil { log.Printf("Bookhoard: CFI→CRE failed to resolve media path: %v", err) return } if epubPath == "" { log.Printf("Bookhoard: CFI→CRE resolved empty epub path for %s", mediaItem.FilePath) return } converter := wsync.NewCFIConverter(epubPath) contextText := "" if progress.ContextText.Valid { contextText = progress.ContextText.String } pct := progress.Percentage.Float64 result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText) if err != nil { log.Printf("Bookhoard: CFI→CRE conversion error: %v", err) return } if result != nil && result.XPointer != "" { progressData.KoreaderXPointer = &result.XPointer log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer) } } 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 { pages := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32) pagesRemaining = &pages } 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), }) }