Files
bookhoard/internal/handlers/koreader.go
T
john-okeefe f6e257e497 fix(sync): web annotations never reached KOReader — bind 400s + unresolvable locators
Two blockers, diagnosed by simulating the plugin against the live
server with real library books:

1. Every KOReader progress push carrying annotations failed the JSON
   bind with 400 ('cannot unmarshal string into ... chapter/page of
   type int') — the plugin sends chapter:'', page:'30', and for CRE
   documents page:'/body/...' — so annotation sync AND progress sync
   failed together. KOReader annotation chapter/page now use FlexInt,
   which accepts numbers, numeric strings, empty strings, and
   non-numeric strings (decoding to 0). The server is deliberately
   liberal here so thin clients can send raw bookmark data.

2. GetMetadata served locators KOReader cannot place, so pulled items
   were junk: web bookmarks leaked 'cfi:epubcfi(...)' positions, web
   PDF highlights had empty pos0 (skipped by the plugin, invisible),
   and web deletions carried no pos0 so tombstones never matched.
   New koreaderPos0 resolver handles every source: device-native
   xpointers pass through untouched (round-trip identical, verified),
   web PDF JSON anchors map to their page number, EPUB CFIs convert
   to CRE xpointers (selection text passed as text-search context for
   exact anchoring), 'page:N' positions strip to the bare number.
   Unresolvable annotations are skipped with a log line instead of
   poisoning devices; tombstones get pos0 injected from the new
   locator columns.

Also: thin clients omit per-annotation percentages (paging docs still
send arithmetic page/total); the server derives them — section
midpoint from the spine char distribution for CRE documents, page/
page-count for fixed formats.
2026-08-18 19:13:38 -04:00

1405 lines
44 KiB
Go

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