package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" wsync "bookhoard/internal/sync" "context" "encoding/json" "fmt" "log" "net/http" "strconv" "strings" "time" "unicode/utf8" "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 annotationSvc *wsync.AnnotationService libraryService LibraryPathResolver bookResolver *services.BookResolver } 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, bookResolver: services.NewBookResolver(db), } } 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, contextText 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 "", "" } // The annotation's own text is the ideal anchor for the converter's // text-search path: clients (thin, underpowered) send only raw // locators, the server resolves them against the actual book. startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, contextText, mediaItem.FormatGroup, epubPath, "") endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "") endCFI := endLoc.CFI // The end conversion carries no context text, so unless it resolved // exactly it degenerates to a percentage fallback anchored at the // document start — useless as a range end. When the START resolved // exactly, derive the end from it: same node, character offset // advanced by the selection's UTF-16 length (the CFI offset unit). if endLoc.Precision != "exact" && startLoc.Precision == "exact" && contextText != "" { endCFI = extendCFIByLength(startLoc.CFI, contextText) } return startLoc.CFI, endCFI } // extendCFIByLength advances a point CFI's trailing character offset by the // UTF-16 length of text (EPUB CFI character offsets are UTF-16 code units). // Selections spanning multiple text nodes produce an out-of-range offset — // harmless: resolution clamps or fails, and consumers fall back to the start. func extendCFIByLength(cfi, text string) string { if cfi == "" || text == "" { return cfi } i := strings.LastIndex(cfi, ":") if i < 0 || !strings.HasSuffix(cfi, ")") { return cfi } off, err := strconv.Atoi(cfi[i+1 : len(cfi)-1]) if err != nil { return cfi } utf16len := 0 for _, r := range text { if r > 0xFFFF { utf16len += 2 } else { utf16len++ } } return cfi[:i+1] + strconv.Itoa(off+utf16len) + ")" } // existingHighlightColor returns the stored color of the highlight matching // the dedup key ("" when none) so device echoes that carry no color never // clobber the web color. func (h *KOReaderHandler) existingHighlightColor(ctx context.Context, mediaItemID, userID pgtype.UUID, dedupKey string) string { if dedupKey == "" { return "" } existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{ UserID: userID, MediaItemID: mediaItemID, DedupKey: pgtype.Text{String: dedupKey, Valid: true}, }) if err != nil { return "" } return existing.Color.String } // deriveAnnotationPercentage computes a percentage for device-pushed // annotations when the client didn't send one (thin clients skip their own // per-annotation page lookups; arithmetic is only free on paging documents). func (h *KOReaderHandler) deriveAnnotationPercentage(ctx context.Context, mediaItemID pgtype.UUID, pos0 string, page int) float64 { mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID) if err != nil { return 0 } formatGroup := wsync.FormatGroup(mediaItem.FormatGroup) if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive { if page > 0 && mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 { return float64(page) / float64(mediaItem.PageCount.Int32) } return 0 } if wsync.IsCREXPointer(pos0) && h.libraryService != nil { if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" { return wsync.NewCFIConverter(epubPath).SectionPercentage(pos0) } } return 0 } 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"` } // FlexInt tolerates the loose types KOReader clients send for optional // numeric fields: JSON numbers, numeric strings ("30"), empty strings // (""), or non-numeric strings ("/body/..." xpointers in `page` for CRE // documents) — the latter decode to 0. Without this, a single annotation // carrying chapter:"" or page:"/body/..." failed the whole request bind // with a 400. type FlexInt int func (f *FlexInt) UnmarshalJSON(b []byte) error { s := strings.TrimSpace(string(b)) if s == "null" || s == `""` { *f = 0 return nil } if n, err := strconv.Atoi(s); err == nil { *f = FlexInt(n) return nil } if strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) { inner := s[1 : len(s)-1] if n, err := strconv.Atoi(inner); err == nil { *f = FlexInt(n) return nil } *f = 0 return nil } if fl, err := strconv.ParseFloat(s, 64); err == nil { *f = FlexInt(int(fl)) return nil } *f = 0 return nil } type KOReaderBookmark struct { Chapter FlexInt `json:"chapter,omitempty"` Datetime string `json:"datetime,omitempty"` Notes string `json:"notes,omitempty"` Pos0 string `json:"pos0,omitempty"` Pos1 string `json:"pos1,omitempty"` Page FlexInt `json:"page,omitempty"` Text string `json:"text,omitempty"` Type string `json:"type,omitempty"` Percentage *float64 `json:"percentage,omitempty"` BookSHA256 string `json:"book_sha256,omitempty"` DedupKey string `json:"dedup_key,omitempty"` } type KOReaderHighlight struct { Chapter FlexInt `json:"chapter,omitempty"` Datetime string `json:"datetime,omitempty"` Notes string `json:"notes,omitempty"` Pos0 string `json:"pos0,omitempty"` Pos1 string `json:"pos1,omitempty"` Page FlexInt `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"` DedupKey string `json:"dedup_key,omitempty"` } type KOReaderNote struct { Chapter FlexInt `json:"chapter,omitempty"` Datetime string `json:"datetime,omitempty"` Notes string `json:"notes,omitempty"` Pos0 string `json:"pos0,omitempty"` Pos1 string `json:"pos1,omitempty"` Page FlexInt `json:"page,omitempty"` Text string `json:"text,omitempty"` Type string `json:"type,omitempty"` Percentage *float64 `json:"percentage,omitempty"` BookSHA256 string `json:"book_sha256,omitempty"` DedupKey string `json:"dedup_key,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"` SHA256 string `json:"sha256,omitempty"` 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"` DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"` DeletedBookmarks []map[string]interface{} `json:"deleted_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"` SHA256 string `json:"sha256,omitempty"` 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) // Uses the shared BookResolver, which also checks per-format hashes // (media_item_formats) so a converted file (KEPUB/PDF) matches too. if book.SHA256 != "" && len(book.SHA256) == 64 { mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, book.SHA256) 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++ } h.processBookAnnotations(c.Request().Context(), device.ID, userID, mediaItemID, book) 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) 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 // The highlight's own text anchors the conversion exactly. epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, hl.Text) pctStart := 0.0 if hl.Percentage != nil { pctStart = *hl.Percentage } if pctStart == 0 { pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(hl.Page)) } deviceData, _ := json.Marshal(map[string]interface{}{ "datetime": hl.Datetime, "pos0": hl.Pos0, "pos1": hl.Pos1, "page": hl.Page, }) // Color semantics: devices render their own default and cannot // round-trip web colors. An echo carries NO color — preserve the // stored (web) color so round-trips never change it. A non-empty // color means the user edited the highlight on the device: map the // device color name and let it win. color := "" if hl.Color != "" { color = mapColorFromKOReader(hl.Color) } dedupKey := hl.DedupKey if dedupKey == "" { dedupKey = wsync.ComputeDedupKey(hl.Text, epubcfiStart, startPos) } if color == "" { color = h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey) } if color == "" { color = "#ffd54f" } h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ MediaItemID: mediaItemID, UserID: userID, SelectionText: hl.Text, StartPosition: startPos, EndPosition: endPos, Color: color, NoteText: hl.Notes, PercentageStart: pctStart, EpubcfiStart: epubcfiStart, EpubcfiEnd: epubcfiEnd, Source: "koreader", DeviceSyncData: deviceData, DedupKey: dedupKey, }) } for _, note := range book.Notes { startPos := note.Pos0 endPos := note.Pos1 epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, note.Text) pctStart := 0.0 if note.Percentage != nil { pctStart = *note.Percentage } if pctStart == 0 { pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(note.Page)) } deviceData, _ := json.Marshal(map[string]interface{}{ "datetime": note.Datetime, "pos0": note.Pos0, "pos1": note.Pos1, "page": note.Page, }) dedupKey := note.DedupKey if dedupKey == "" { dedupKey = wsync.ComputeDedupKey(note.Text, epubcfiStart, startPos) } h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ MediaItemID: mediaItemID, UserID: userID, SelectionText: note.Text, StartPosition: startPos, EndPosition: endPos, Color: h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey), NoteText: note.Notes, PercentageStart: pctStart, EpubcfiStart: epubcfiStart, EpubcfiEnd: epubcfiEnd, Source: "koreader", DeviceSyncData: deviceData, DedupKey: dedupKey, }) } 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, }) dedupKey := bookmark.DedupKey if dedupKey == "" { dedupKey = wsync.ComputeDedupKey(bookmark.Text, "", position) } h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{ MediaItemID: mediaItemID, UserID: userID, Title: bookmark.Text, Position: position, ChapterNumber: int32(bookmark.Chapter), Source: "koreader", DeviceSyncData: deviceData, DedupKey: dedupKey, }) } } 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 mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) || mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) { // Image-based fixed content (fixed-layout comic EPUBs, PDF, // comic archives) has no extractable text, so CRE→CFI conversion // cannot succeed. The page index (page/total_pages) is the // canonical locator. Keep the incoming xpointer for device-native // restore; the web reader restores by page. log.Printf("Bookhoard: CRE→CFI skipped for %s format", mediaItem.FormatGroup) } 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) if err != nil { return err } h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book) return nil } _, 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", }, ) h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book) 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, } // CFI/xpointer are meaningless for image-based fixed-layout content; the // page index is the canonical locator. Only return them for reflowable docs. formatGroup := wsync.FormatGroup(mediaItem.FormatGroup) isFixed := formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive if !isFixed { 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.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{ MediaItemID: pgBookUUID, UserID: pgUserID, }) annotationsResponse := KOReaderAnnotations{ Highlights: []KOReaderHighlight{}, Notes: []KOReaderNote{}, Bookmarks: []KOReaderBookmark{}, } for _, ann := range annotations { if ann.AnnotationType == "highlight" { // Selection text doubles as the converter's text-search context. pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, ann.SelectionText) pos1 := h.koreaderPos0(c, mediaItem, ann.EndPosition.String, ann.EpubcfiEnd.String, ann.SelectionText) if pos0 == "" { // Nothing the device could place — serving a locator it can't // resolve would create junk bookmarks that re-push as // duplicates, so skip instead. log.Printf("Bookhoard: GetMetadata skip highlight %s (no resolvable pos0)", ann.ID) continue } // Old web highlights carry no end anchor, and converted range // CFIs resolve to their start — either way pos1 collapses onto // pos0 and the device paints a zero-width highlight. Derive the // end by advancing the start's character offset by the length // of the selected text. if pos1 == "" || pos1 == pos0 { pos1 = extendXPointerByLength(pos0, ann.SelectionText) } highlight := KOReaderHighlight{ Text: ann.SelectionText, Pos0: pos0, Pos1: pos1, // Web colors flow to the device, mapped to KOReader's named // palette. Round-trip safety: the device suppresses the color // when echoing un-edited applied entries (a pink→purple // palette mismatch must not rewrite the stored hex), and an // actual device edit pushes its color, which wins. Color: mapColorToKOReader(ann.Color.String), Datetime: ann.CreatedAt.Time.Format(time.RFC3339), DedupKey: ann.DedupKey.String, } if ann.NoteText.Valid && ann.NoteText.String != "" { highlight.Notes = ann.NoteText.String } annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight) } else if ann.AnnotationType == "note" { pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, "") if pos0 == "" { log.Printf("Bookhoard: GetMetadata skip note %s (no resolvable pos0)", ann.ID) continue } annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{ Text: ann.SelectionText, Pos0: pos0, Datetime: ann.CreatedAt.Time.Format(time.RFC3339), DedupKey: ann.DedupKey.String, }) } } bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{ MediaItemID: pgBookUUID, UserID: pgUserID, }) for _, bm := range bookmarks { pos0 := h.koreaderPos0(c, mediaItem, bm.Position.String, bm.CfiPosition.String, "") if pos0 == "" { log.Printf("Bookhoard: GetMetadata skip bookmark %s (no resolvable pos0)", bm.ID) continue } koreaderBookmark := KOReaderBookmark{ Text: bm.Title, Pos0: pos0, Pos1: pos0, Datetime: bm.CreatedAt.Time.Format(time.RFC3339), DedupKey: bm.DedupKey.String, } if bm.Notes.Valid && bm.Notes.String != "" { koreaderBookmark.Notes = bm.Notes.String } if bm.ChapterNumber.Valid { koreaderBookmark.Chapter = FlexInt(bm.ChapterNumber.Int32) } annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark) } cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), 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 // KOReader deletes by matching pos0. Device-pushed annotations carry // it in device_sync_data; web-created ones don't (their locator is // converted at serve time), so resolve it from the stored columns. if dd["pos0"] == nil || dd["pos0"] == "" { if pos0 := h.koreaderPos0(c, mediaItem, ts.StartPosition.String, ts.EpubcfiStart.String, ""); pos0 != "" { dd["pos0"] = pos0 } } 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) } metadata := KOReaderMetadata{ UUID: bookUUID.String(), SHA256: mediaItem.FileSha256.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) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText 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, contextText, mediaItem.FormatGroup, epubPath, "") if loc.Position != "" && loc.Position != epubcfi { return loc.Position } return "" } // pdfRectAnchor is the JSON locator the web reader stores in epubcfi_start // for PDF text highlights (page-fraction rects; page index is 0-based). type pdfRectAnchor struct { V int `json:"v"` Page int `json:"page"` Rects [][]float64 `json:"rects"` } // koreaderPos0 resolves a device-native KOReader pos0 from an annotation's // stored locators, whatever the source. Resolution order: // // extendXPointerByLength advances a CRE xpointer's trailing text-node // character offset by the rune length of text, so a highlight with only a // start anchor still gets a plausible (non-collapsed) end for drawing. // Overshooting the node just clamps on the device. func extendXPointerByLength(xp, text string) string { if xp == "" || text == "" { return xp } i := strings.LastIndex(xp, ".") if i < 0 { return xp } off, err := strconv.Atoi(xp[i+1:]) if err != nil { return xp } return xp[:i+1] + strconv.Itoa(off+utf8.RuneCountInString(text)) } // KOReader paints highlight colors from a fixed set of names // (Blitbuffer.HIGHLIGHT_COLORS); the web reader uses hex swatches. Map at // the boundary so each side always receives something it can render; // unmappable values fall back to each side's default (yellow). var koreaderColorFromName = map[string]string{ "yellow": "#ffd54f", "orange": "#ffd54f", "green": "#a5d6a7", "olive": "#a5d6a7", "cyan": "#90caf9", "blue": "#90caf9", "purple": "#ce93d8", "red": "#f48fb1", } // mapColorFromKOReader normalizes a device color name to a web hex // swatch (default yellow) when ingesting device pushes. func mapColorFromKOReader(name string) string { if hex, ok := koreaderColorFromName[strings.ToLower(strings.TrimSpace(name))]; ok { return hex } return "#ffd54f" } var koreaderColorFromHex = map[string]string{ "#ffd54f": "yellow", "#a5d6a7": "green", "#90caf9": "blue", "#ce93d8": "purple", "#f48fb1": "purple", } // mapColorToKOReader normalizes a web hex swatch to the nearest KOReader // color name (default yellow) when serving to devices. Pink maps to purple // (the palette's closest); round-trip drift is prevented on the device by // suppressing echo colors for un-edited applied entries. func mapColorToKOReader(hex string) string { if name, ok := koreaderColorFromHex[strings.ToLower(strings.TrimSpace(hex))]; ok { return name } return "yellow" } // 1. A device-native CRE xpointer ("/body/...") in startPosition wins — // round-trip identical for KOReader-pushed annotations (converting the // stored CFI instead could drift and duplicate on the device). // 2. The web reader's PDF JSON anchor → bare page number (KOReader paging // documents use the page number as pos0). // 3. A stored EPUB CFI (epubcfi_start, or startPosition without the // reader's "cfi:" prefix) → converted to a CRE xpointer, with // contextText (the selection text) enabling the text-search fallback. // 4. A "page:N" or bare-numeric position → the bare number. // // Returns "" when nothing usable exists; callers skip such annotations so // devices never receive locators they cannot place. func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.MediaItems, startPosition, epubcfi, contextText string) string { if wsync.IsCREXPointer(startPosition) { return startPosition } if strings.HasPrefix(epubcfi, "{") { var anchor pdfRectAnchor if json.Unmarshal([]byte(epubcfi), &anchor) == nil && anchor.Page >= 0 { return strconv.Itoa(anchor.Page) } } cfi := epubcfi if cfi == "" && strings.HasPrefix(startPosition, "cfi:") { cfi = strings.TrimPrefix(startPosition, "cfi:") } if cfi != "" && wsync.IsStandardEPUBCFI(cfi) { if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText); converted != "" { return converted } // Conversion failed; fall through so numeric positions still work. if wsync.IsCREXPointer(cfi) { return cfi } } if p := strings.TrimPrefix(startPosition, "page:"); p != "" && parsePageInt(p) >= 0 { return p } return "" } func parsePageInt(s string) int64 { var n int64 for _, r := range s { if r < '0' || r > '9' { return -1 } n = n*10 + int64(r-'0') } return n } 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.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{ MediaItemID: pgItemUUID, UserID: pgUserID, }) bookmarkCount = len(annotations) libraryBooks = append(libraryBooks, KOReaderLibraryBook{ UUID: uuid.UUID(item.ID.Bytes).String(), SHA256: item.FileSha256.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 (format-aware: also checks media_item_formats) mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, req.BookSHA256) 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.bookResolver.ResolveBySHA256(ctx, bookmark.BookSHA256) 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) } if h.annotationSvc != nil { deviceData, _ := json.Marshal(map[string]interface{}{ "datetime": bookmark.Datetime, "pos0": bookmark.Pos0, "page": bookmark.Page, }) 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++ } } } 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.bookResolver.ResolveBySHA256(ctx, note.BookSHA256) 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) } if h.annotationSvc != nil { deviceData, _ := json.Marshal(map[string]interface{}{ "datetime": note.Datetime, "pos0": note.Pos0, "page": note.Page, }) 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++ } } } 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.bookResolver.ResolveBySHA256(ctx, highlight.BookSHA256) 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 := "#ffd54f" if highlight.Color != "" { color = mapColorFromKOReader(highlight.Color) } if h.annotationSvc != nil { epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1, highlight.Text) 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++ } } } 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), }) }