package handlers import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "regexp" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) type KoboHandler struct { db *database.Queries connManager *wsync.ConnectionManager } func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler { return &KoboHandler{db: db, connManager: connManager} } // mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies // Enhanced Kobo Sync - ContentId Mapping Logic func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) { // Step 1: Try direct ContentId lookup in device_catalogs table catalog, err := h.db.GetDeviceCatalogByKoboContentId(ctx.Request().Context(), contentId) if err == nil && catalog.ID.Valid { // Found! Use canonical Bookhoard UUID return uuid.UUID(catalog.BookhoardUuid.Bytes), nil, "catalog_match" } // Step 2: ContentId not found - check if it looks like a SHA-256 hash if len(contentId) == 64 && looksLikeSHA256(contentId) { // Try to find media item by SHA-256 mediaItem, err := h.db.GetMediaItemBySHA256(ctx.Request().Context(), pgtype.Text{String: contentId, Valid: true}) if err == nil { // Found by SHA-256! Create device catalog entry for future lookups _, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{ DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true}, BookhoardUuid: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true}, KoboContentID: contentId, ContentIDType: pgtype.Text{String: "sha256", Valid: true}, Available: pgtype.Bool{Bool: true, Valid: true}, DeliveryDate: pgtype.Timestamptz{Time: time.Now(), Valid: true}, DeliveryMethod: pgtype.Text{String: "sync", Valid: true}, }) return uuid.UUID(mediaItem.ID.Bytes), nil, "sha256_match" } } // Step 3: Try to parse as UUID directly if parsedUUID, err := uuid.Parse(contentId); err == nil { // Check if this UUID exists in media_items mediaItem, err := h.db.GetMediaItem(ctx.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) if err == nil { // Create device catalog entry _, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{ DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true}, BookhoardUuid: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true}, KoboContentID: contentId, ContentIDType: pgtype.Text{String: "bookhoard_uuid", Valid: true}, Available: pgtype.Bool{Bool: true, Valid: true}, DeliveryDate: pgtype.Timestamptz{Time: time.Now(), Valid: true}, DeliveryMethod: pgtype.Text{String: "sync", Valid: true}, }) return parsedUUID, nil, "uuid_match" } } // Step 4: Not found - return error for unlinked book return uuid.Nil, fmt.Errorf("unlinked book: ContentId %s not found", contentId), "unlinked" } // mapBookhoardUUIDToKoboContentId maps Bookhoard UUID to Kobo ContentId // Creates new entry in device_catalogs if not exists func (h *KoboHandler) mapBookhoardUUIDToKoboContentId(c *echo.Context, bookhoardUUID uuid.UUID, deviceID uuid.UUID) (string, error) { // Check if catalog entry already exists catalog, err := h.db.GetDeviceCatalogByBookhoardUUID(c.Request().Context(), database.GetDeviceCatalogByBookhoardUUIDParams{ DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, BookhoardUuid: pgtype.UUID{Bytes: bookhoardUUID, Valid: true}, }) if err == nil && catalog.ID.Valid { return catalog.KoboContentID, nil } // Get media item to check for existing Kobo content ID mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookhoardUUID, Valid: true}) if err != nil { return "", err } // Generate Kobo ContentId based on priority: // 1. Use existing kobo_content_id from media_items // 2. Use existing entitlement_id from media_items // 3. Generate new "kobo_" prefixed UUID var koboContentId string contentIdType := "bookhoard_generated" if mediaItem.KoboContentID.Valid && mediaItem.KoboContentID.String != "" { koboContentId = mediaItem.KoboContentID.String contentIdType = "kobo_metadata" } else if mediaItem.EntitlementID.Valid && mediaItem.EntitlementID.String != "" { koboContentId = mediaItem.EntitlementID.String contentIdType = "entitlement_id" } else { koboContentId = "kobo_" + uuid.New().String() contentIdType = "kobo_generated" } // Create device catalog entry _, err = h.db.CreateDeviceCatalog(c.Request().Context(), database.CreateDeviceCatalogParams{ DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: bookhoardUUID, Valid: true}, BookhoardUuid: pgtype.UUID{Bytes: bookhoardUUID, Valid: true}, KoboContentID: koboContentId, ContentIDType: pgtype.Text{String: contentIdType, Valid: true}, Available: pgtype.Bool{Bool: true, Valid: true}, DeliveryDate: pgtype.Timestamptz{Time: time.Now(), Valid: true}, DeliveryMethod: pgtype.Text{String: "opds", Valid: true}, }) if err != nil { return "", err } return koboContentId, nil } // getCollectionMetadataForBook retrieves collection names for a book func (h *KoboHandler) getCollectionMetadataForBook(c *echo.Context, bookhoardUUID uuid.UUID, deviceID uuid.UUID) ([]string, error) { device := c.Get("device").(database.Devices) pgDeviceID := pgtype.UUID{Bytes: device.ID.Bytes, Valid: true} // Get collections for this book collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: bookhoardUUID, Valid: true}) if err != nil { return []string{}, nil } var collectionNames []string // For each collection, check if there's a device-specific shelf mapping for _, collection := range collections { mapping, err := h.db.GetDeviceShelfMapping(c.Request().Context(), database.GetDeviceShelfMappingParams{ DeviceID: pgDeviceID, CollectionID: pgtype.UUID{Bytes: collection.ID.Bytes, Valid: true}, }) if err == nil && mapping.ID.Valid && mapping.DeviceShelfName.Valid { // Use device-specific shelf name collectionNames = append(collectionNames, mapping.DeviceShelfName.String) } else if collection.Name != "" { // Fall back to collection name collectionNames = append(collectionNames, collection.Name) } } return collectionNames, nil } // looksLikeSHA256 checks if a string looks like a SHA-256 hash func looksLikeSHA256(s string) bool { if len(s) != 64 { return false } matched, _ := regexp.MatchString("^[0-9a-fA-F]{64}$", s) return matched } // calculateFileSHA256 calculates SHA-256 hash of file path func calculateFileSHA256(filePath string) string { hash := sha256.Sum256([]byte(filePath)) return hex.EncodeToString(hash[:]) } type KoboDeviceInfo struct { DeviceID string `json:"DeviceId"` Model string `json:"Model"` SerialNumber string `json:"SerialNumber"` Firmware string `json:"Firmware,omitempty"` } type KoboReadingSync struct { ContentId string `json:"ContentId"` PercentRead float64 `json:"PercentRead"` EntitlementId string `json:"EntitlementId"` RemainingTimeMinutes int `json:"RemainingTimeMinutes"` FirstReadTime string `json:"FirstReadTime,omitempty"` LastModified string `json:"LastModified"` ContentType string `json:"ContentType,omitempty"` } type KoboBookmarkSync struct { BookmarkId string `json:"BookmarkId"` ContentId string `json:"ContentId"` BookmarkText string `json:"BookmarkText"` BookmarkType string `json:"BookmarkType"` BookmarkTitle string `json:"BookmarkTitle"` DateCreated string `json:"DateCreated"` Chapter int `json:"Chapter,omitempty"` Hidden bool `json:"Hidden,omitempty"` } type KoboMarkupRequest struct { ReadingSync []KoboReadingSync `json:"ReadingSync"` BookmarkSync []KoboBookmarkSync `json:"BookmarkSync,omitempty"` } type KoboLibraryBook struct { ContentId string `json:"ContentId"` ContentType string `json:"ContentType"` Title string `json:"Title"` Author string `json:"Author"` PercentRead float64 `json:"PercentRead"` PagesRemaining *int `json:"PagesRemaining,omitempty"` BookmarkCount int `json:"BookmarkCount"` LastModified string `json:"LastModified"` EntitlementId string `json:"EntitlementId,omitempty"` Revision int `json:"Revision"` MimeType string `json:"MimeType"` FileSize int64 `json:"FileSize"` Categories []string `json:"Categories,omitempty"` BookhoardUUID string `json:"BookhoardUUID,omitempty"` } type KoboLibraryResponse struct { LibrarySync []KoboLibraryBook `json:"library_sync"` TotalBooks int `json:"total_books"` LastSync string `json:"last_sync"` } type KoboInitResponse struct { Resources map[string]interface{} `json:"Resources"` UserKey string `json:"UserKey"` } type KoboSyncStatus struct { Status string `json:"Status"` MarkupsSynced int `json:"MarkupsSynced"` BookmarksSynced int `json:"BookmarksSynced"` } type KoboServerSyncData struct { ContentId string `json:"ContentId"` PercentRead float64 `json:"PercentRead"` Bookmarks []KoboBookmarkSync `json:"Bookmarks,omitempty"` Highlights []KoboBookmarkSync `json:"Highlights,omitempty"` LastModified string `json:"LastModified"` } type KoboServerSyncResponse struct { BooksSynced int `json:"BooksSynced"` BookmarksSent int `json:"BookmarksSent"` HighlightsSent int `json:"HighlightsSent"` } type KoboAnalyticsTest struct { ContentId string `json:"ContentId"` ReadingEvent string `json:"ReadingEvent"` RemainingTimeMin int `json:"RemainingTimeMin"` PercentRead float64 `json:"PercentRead"` } func (h *KoboHandler) Initialization(c *echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes deviceID := device.ID.Bytes pgUserID := pgtype.UUID{Bytes: userID, Valid: true} deviceUUID := uuid.UUID(deviceID) 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", }) } librarySync := []KoboLibraryBook{} for _, item := range mediaItems { bookhoardUUID := uuid.UUID(item.ID.Bytes) progress, _ := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{ MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}, UserID: pgUserID, }) percentRead := 0.0 lastModified := time.Now().Format(time.RFC3339) var pagesRemaining *int if progress.ID.Valid { percentRead = progress.Percentage.Float64 * 100 if progress.LastReadAt.Valid { lastModified = progress.LastReadAt.Time.Format(time.RFC3339) } if progress.TotalPages.Valid && progress.CurrentPage.Valid { remaining := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32) pagesRemaining = &remaining } } bookmarkCount := 0 annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}, UserID: pgUserID, }) bookmarkCount = len(annotations) author := "" if item.Author.Valid { author = item.Author.String } // Use ContentId mapping instead of direct UUID koboContentId, err := h.mapBookhoardUUIDToKoboContentId(c, bookhoardUUID, deviceUUID) if err != nil { // Fallback to entitlement_id or generate new one if item.EntitlementID.Valid && item.EntitlementID.String != "" { koboContentId = item.EntitlementID.String } else { koboContentId = "kobo_" + bookhoardUUID.String() } } mimeType := item.MimeType.String if !item.MimeType.Valid { mimeType = "" } fileSize := int64(0) if item.FileSize.Valid { fileSize = item.FileSize.Int64 } revision := 1 if item.RevisionNumber.Valid { revision = int(item.RevisionNumber.Int32) } contentType := "6" if strings.Contains(mimeType, "pdf") { contentType = "5" } // Get collection metadata for this book collections, _ := h.getCollectionMetadataForBook(c, bookhoardUUID, deviceUUID) librarySync = append(librarySync, KoboLibraryBook{ ContentId: koboContentId, ContentType: contentType, Title: item.Title, Author: author, PercentRead: percentRead, PagesRemaining: pagesRemaining, BookmarkCount: bookmarkCount, LastModified: lastModified, EntitlementId: koboContentId, Revision: revision, MimeType: mimeType, FileSize: fileSize, Categories: collections, BookhoardUUID: bookhoardUUID.String(), }) } return c.JSON(http.StatusOK, KoboLibraryResponse{ LibrarySync: librarySync, TotalBooks: len(librarySync), LastSync: time.Now().Format(time.RFC3339), }) } func (h *KoboHandler) LibrarySync(c *echo.Context) error { return h.Initialization(c) } func (h *KoboHandler) Markup(c *echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes deviceID := device.ID.Bytes pgUserID := pgtype.UUID{Bytes: userID, Valid: true} deviceUUID := uuid.UUID(deviceID) var req KoboMarkupRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "invalid request format", }) } markupsSynced := 0 bookmarksSynced := 0 unlinkedBooks := 0 for _, readingSync := range req.ReadingSync { // Use ContentId mapping with fallback logic bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID) if err != nil || bookhoardUUID == uuid.Nil { // Unlinked book detected unlinkedBooks++ // TODO: Create unlinked book entry for manual resolution continue } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} percentage := readingSync.PercentRead / 100.0 _, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, Percentage: pgtype.Float8{Float64: percentage, Valid: true}, LastSyncDevice: pgtype.Text{String: "kobo", Valid: true}, LastSyncSource: pgtype.Text{String: "kobo", Valid: true}, }) if err == nil { markupsSynced++ h.connManager.BroadcastProgressUpdate( bookhoardUUID, percentage, wsync.SourceDevice{ ID: uuid.UUID(userID).String(), Name: device.DeviceName, Type: "kobo", }, ) } } for _, bookmarkSync := range req.BookmarkSync { // Use ContentId mapping with fallback logic bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, bookmarkSync.ContentId, deviceUUID) if err != nil || bookhoardUUID == uuid.Nil { // Unlinked book - skip continue } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} switch bookmarkSync.BookmarkType { case "annotation": if bookmarkSync.BookmarkText != "" { h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, SelectionText: bookmarkSync.BookmarkText, StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, Color: pgtype.Text{String: "#ffff00", Valid: true}, }) bookmarksSynced++ } case "bookmark": if bookmarkSync.BookmarkText != "" { h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, Content: bookmarkSync.BookmarkText, Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, }) bookmarksSynced++ } case "last-read-place": if bookmarkSync.BookmarkId != "" { var epubcfi string if strings.HasPrefix(bookmarkSync.BookmarkId, "epubcfi(") { epubcfi = strings.TrimPrefix(bookmarkSync.BookmarkId, "epubcfi(") epubcfi = strings.TrimSuffix(epubcfi, ")") } _, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, Epubcfi: pgtype.Text{String: epubcfi, Valid: true}, Chapter: pgtype.Int4{Int32: int32(bookmarkSync.Chapter), Valid: true}, ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true}, LastSyncDevice: pgtype.Text{String: "kobo", Valid: true}, LastSyncSource: pgtype.Text{String: "kobo", Valid: true}, }) if err != nil { fmt.Printf("Failed to store last-read-place: %v", err) } bookmarksSynced++ } } } _, 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", }) } response := KoboSyncStatus{ Status: "Success", MarkupsSynced: markupsSynced, BookmarksSynced: bookmarksSynced, } // Include unlinked books count if any if unlinkedBooks > 0 { // For now, just log it. In production, this should trigger an alert // and the unlinked books should be stored for manual resolution response.Status = "Partial" } return c.JSON(http.StatusOK, response) } func (h *KoboHandler) Bookmark(c *echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes deviceID := device.ID.Bytes pgUserID := pgtype.UUID{Bytes: userID, Valid: true} deviceUUID := uuid.UUID(deviceID) var req struct { BookmarkSync []KoboBookmarkSync `json:"BookmarkSync"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "invalid request format", }) } bookmarksSynced := 0 for _, bookmarkSync := range req.BookmarkSync { // Use ContentId mapping with fallback logic bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, bookmarkSync.ContentId, deviceUUID) if err != nil || bookhoardUUID == uuid.Nil { // Unlinked book - skip continue } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} switch bookmarkSync.BookmarkType { case "annotation": if bookmarkSync.BookmarkText != "" { h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, SelectionText: bookmarkSync.BookmarkText, StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, Color: pgtype.Text{String: "#ffff00", Valid: true}, }) bookmarksSynced++ } case "bookmark": if bookmarkSync.BookmarkText != "" { h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, Content: bookmarkSync.BookmarkText, Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, }) bookmarksSynced++ } } } _, 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.StatusOK, KoboSyncStatus{ Status: "Success", BookmarksSynced: bookmarksSynced, MarkupsSynced: 0, }) } func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes deviceID := device.ID.Bytes pgUserID := pgtype.UUID{Bytes: userID, Valid: true} deviceUUID := uuid.UUID(deviceID) var req []KoboAnalyticsTest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "invalid request format", }) } for _, test := range req { // Use ContentId mapping with fallback logic bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID) if err != nil || bookhoardUUID == uuid.Nil { // Unlinked book - skip continue } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} percentage := test.PercentRead / 100.0 _, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, Percentage: pgtype.Float8{Float64: percentage, Valid: true}, LastSyncDevice: pgtype.Text{String: "kobo", Valid: true}, LastSyncSource: pgtype.Text{String: "kobo", Valid: true}, }) if err == nil { h.connManager.BroadcastProgressUpdate( bookhoardUUID, percentage, wsync.SourceDevice{ ID: uuid.UUID(userID).String(), Name: device.DeviceName, Type: "kobo", }, ) } } _, 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.StatusOK, map[string]interface{}{ "Status": "Success", }) } func parseKoboDeviceHeader(c *echo.Context) (KoboDeviceInfo, error) { deviceHeader := c.Request().Header.Get("x-kobo-device") if deviceHeader == "" { return KoboDeviceInfo{}, fmt.Errorf("missing x-kobo-device header") } var device KoboDeviceInfo if err := json.Unmarshal([]byte(deviceHeader), &device); err != nil { return KoboDeviceInfo{}, fmt.Errorf("invalid device header format") } return device, nil } func (h *KoboHandler) SyncFromServer(c *echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes deviceID := device.ID.Bytes pgUserID := pgtype.UUID{Bytes: userID, Valid: true} deviceUUID := uuid.UUID(deviceID) var req []KoboServerSyncData if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "invalid request format", }) } booksSynced := 0 bookmarksSent := 0 highlightsSent := 0 for _, syncData := range req { // Use ContentId mapping with fallback logic bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID) if err != nil || bookhoardUUID == uuid.Nil { // Unlinked book - skip continue } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} percentage := syncData.PercentRead / 100.0 _, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, Percentage: pgtype.Float8{Float64: percentage, Valid: true}, LastSyncDevice: pgtype.Text{String: "kobo", Valid: true}, LastSyncSource: pgtype.Text{String: "bookhoard", Valid: true}, }) if err == nil { booksSynced++ } for _, bookmark := range syncData.Bookmarks { if bookmark.BookmarkType == "bookmark" { h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, Content: bookmark.BookmarkText, Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, }) bookmarksSent++ } else if bookmark.BookmarkType == "annotation" { h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, SelectionText: bookmark.BookmarkText, StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, Color: pgtype.Text{String: "#ffff00", Valid: true}, }) highlightsSent++ } } for _, highlight := range syncData.Highlights { h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, SelectionText: highlight.BookmarkText, StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true}, EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true}, Color: pgtype.Text{String: "#ffff00", Valid: true}, }) highlightsSent++ } } _, 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.StatusOK, KoboServerSyncResponse{ BooksSynced: booksSynced, BookmarksSent: bookmarksSent, HighlightsSent: highlightsSent, }) }