package handlers import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" "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 progressSvc *wsync.ProgressService } func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler { return &KoboHandler{db: db, connManager: connManager} } func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) { h.progressSvc = svc } // 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 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 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 } 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 { bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID) if err != nil || bookhoardUUID == uuid.Nil { unlinkedBooks++ continue } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} percentage := readingSync.PercentRead / 100.0 if h.progressSvc != nil { _, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, Source: "kobo", DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, Percentage: &percentage, DeviceType: "kobo", DeviceName: device.DeviceName, Broadcast: true, }) } else { _, 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++ } } 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, ")") } chapter := bookmarkSync.Chapter chapterProgress := 0.5 if h.progressSvc != nil { _, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, Source: "kobo", DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, Epubcfi: &epubcfi, Chapter: &chapter, ChapterProgress: &chapterProgress, DeviceType: "kobo", DeviceName: device.DeviceName, Broadcast: false, }) } else { _, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, Epubcfi: pgtype.Text{String: epubcfi, Valid: epubcfi != ""}, 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 { bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID) if err != nil || bookhoardUUID == uuid.Nil { continue } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} percentage := test.PercentRead / 100.0 if h.progressSvc != nil { _, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, Source: "kobo", DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, Percentage: &percentage, DeviceType: "kobo", DeviceName: device.DeviceName, Broadcast: true, }) } else { _, 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}, }) } } _, 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 (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 { bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID) if err != nil || bookhoardUUID == uuid.Nil { continue } pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} percentage := syncData.PercentRead / 100.0 if h.progressSvc != nil { _, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, Source: "bookhoard", DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, Percentage: &percentage, DeviceType: "kobo", DeviceName: device.DeviceName, Broadcast: false, }) } else { _, 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, }) }