package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" wsync "bookhoard/internal/sync" "context" "encoding/json" "fmt" "log" "net/http" "strconv" "strings" "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 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, "") return startLoc.CFI, endLoc.CFI } // 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"` } 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"` } 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"` } 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, }) h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ MediaItemID: mediaItemID, UserID: userID, SelectionText: hl.Text, StartPosition: startPos, EndPosition: endPos, Color: hl.Color, NoteText: hl.Notes, PercentageStart: pctStart, EpubcfiStart: epubcfiStart, EpubcfiEnd: epubcfiEnd, Source: "koreader", DeviceSyncData: deviceData, }) } for _, note := range book.Notes { startPos := note.Pos0 endPos := note.Pos1 epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, 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, }) h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ MediaItemID: mediaItemID, UserID: userID, SelectionText: note.Text, StartPosition: startPos, EndPosition: endPos, NoteText: note.Notes, PercentageStart: pctStart, EpubcfiStart: epubcfiStart, EpubcfiEnd: epubcfiEnd, Source: "koreader", DeviceSyncData: deviceData, }) } for _, bookmark := range book.Bookmarks { position := "" if bookmark.Pos0 != "" { position = bookmark.Pos0 } else if bookmark.Page > 0 { position = fmt.Sprintf("page:%d", bookmark.Page) } deviceData, _ := json.Marshal(map[string]interface{}{ "datetime": bookmark.Datetime, "pos0": bookmark.Pos0, "page": bookmark.Page, }) h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{ MediaItemID: mediaItemID, UserID: userID, Title: bookmark.Text, Position: position, ChapterNumber: int32(bookmark.Chapter), Source: "koreader", DeviceSyncData: deviceData, }) } } func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error { ctx := c.Request().Context() 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 } highlight := KOReaderHighlight{ Text: ann.SelectionText, Pos0: pos0, Pos1: pos1, Color: ann.Color.String, Datetime: ann.CreatedAt.Time.Format(time.RFC3339), } if ann.NoteText.Valid && ann.NoteText.String != "" { highlight.Notes = ann.NoteText.String } annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight) } else if ann.AnnotationType == "note" { 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), }) } } 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), } 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: // // 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 := "#ffff00" if highlight.Color != "" { color = 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), }) }