package handlers import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" "encoding/json" "fmt" "log" "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 annotationSvc *wsync.AnnotationService libraryService LibraryPathResolver } 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 } func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) { h.annotationSvc = svc } func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) { h.libraryService = 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"` DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"` } type KoboDeletedAnnotation struct { ContentId string `json:"ContentId"` BookmarkId string `json:"BookmarkId"` Type string `json:"Type"` } 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.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{ 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 processedBooks := make(map[pgtype.UUID]string) 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} processedBooks[pgMediaUUID] = readingSync.ContentId percentage := readingSync.PercentRead / 100.0 // Kobo only sends a percentage. For fixed-layout & comic formats the page // index is the canonical locator, so derive it from the known page count. var currentPage, totalPages *int if mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID); mErr == nil { if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) || mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) { if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 { total := int(mediaItem.PageCount.Int32) page := wsync.PercentageToPage(percentage, total) currentPage = &page totalPages = &total } } } 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, CurrentPage: currentPage, TotalPages: totalPages, 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} processedBooks[pgMediaUUID] = bookmarkSync.ContentId switch bookmarkSync.BookmarkType { case "annotation": if bookmarkSync.BookmarkText != "" { if h.annotationSvc != nil { deviceData, _ := json.Marshal(map[string]interface{}{ "bookmark_id": bookmarkSync.BookmarkId, "date_created": bookmarkSync.DateCreated, }) result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, SelectionText: bookmarkSync.BookmarkText, StartPosition: bookmarkSync.BookmarkId, EndPosition: bookmarkSync.BookmarkId, Color: "#ffff00", NoteText: bookmarkSync.BookmarkTitle, Source: "kobo", DeviceSyncData: deviceData, }) if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { bookmarksSynced++ } } else { 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 != "" { if h.annotationSvc != nil { deviceData, _ := json.Marshal(map[string]interface{}{ "bookmark_id": bookmarkSync.BookmarkId, "date_created": bookmarkSync.DateCreated, }) result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, Title: bookmarkSync.BookmarkText, Position: bookmarkSync.BookmarkId, ChapterNumber: int32(bookmarkSync.Chapter), Source: "kobo", DeviceSyncData: deviceData, }) if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { bookmarksSynced++ } } else { 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 var convertedCFI *string var contextText *string if epubcfi != "" && h.libraryService != nil { mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID) if mErr == nil { formatGroup := wsync.FormatGroup(mediaItem.FormatGroup) if formatGroup != wsync.FormatGroupFixedLayout && formatGroup != wsync.FormatGroupComicArchive { convertedCFI, contextText = h.convertKoboCFIToStandard(c, mediaItem, epubcfi) } } } if convertedCFI != nil { epubcfi = *convertedCFI } 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, ContextText: contextText, 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, } if h.annotationSvc != nil && len(processedBooks) > 0 { cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true} for mediaItemID, contentId := range processedBooks { tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{ MediaItemID: mediaItemID, UserID: pgUserID, DeletedAt: cutoff, }) for _, ts := range tombstones { var dd map[string]interface{} if len(ts.DeviceSyncData) > 0 { json.Unmarshal(ts.DeviceSyncData, &dd) } bookmarkID, _ := dd["bookmark_id"].(string) if bookmarkID == "" { continue } response.DeletedAnnotations = append(response.DeletedAnnotations, KoboDeletedAnnotation{ ContentId: contentId, BookmarkId: bookmarkID, Type: ts.AnnotationType, }) } } } // 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 != "" { if h.annotationSvc != nil { deviceData, _ := json.Marshal(map[string]interface{}{ "bookmark_id": bookmarkSync.BookmarkId, "date_created": bookmarkSync.DateCreated, }) result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, SelectionText: bookmarkSync.BookmarkText, StartPosition: bookmarkSync.BookmarkId, EndPosition: bookmarkSync.BookmarkId, Color: "#ffff00", NoteText: bookmarkSync.BookmarkTitle, Source: "kobo", DeviceSyncData: deviceData, }) if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { bookmarksSynced++ } } else { 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 != "" { if h.annotationSvc != nil { deviceData, _ := json.Marshal(map[string]interface{}{ "bookmark_id": bookmarkSync.BookmarkId, "date_created": bookmarkSync.DateCreated, }) result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, Title: bookmarkSync.BookmarkText, Position: bookmarkSync.BookmarkId, ChapterNumber: int32(bookmarkSync.Chapter), Source: "kobo", DeviceSyncData: deviceData, }) if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { bookmarksSynced++ } } else { 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" { if h.annotationSvc != nil { result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, Title: bookmark.BookmarkText, Position: bookmark.BookmarkId, Source: "kobo", }) if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { bookmarksSent++ } } else { 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" { if h.annotationSvc != nil { result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, SelectionText: bookmark.BookmarkText, StartPosition: bookmark.BookmarkId, EndPosition: bookmark.BookmarkId, Color: "#ffff00", Source: "kobo", }) if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { highlightsSent++ } } else { 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 { if h.annotationSvc != nil { result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ MediaItemID: pgMediaUUID, UserID: pgUserID, SelectionText: highlight.BookmarkText, StartPosition: highlight.BookmarkId, EndPosition: highlight.BookmarkId, Color: "#ffff00", Source: "kobo", }) if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { highlightsSent++ } } else { 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, }) } func (h *KoboHandler) convertKoboCFIToStandard(c *echo.Context, mediaItem database.MediaItems, kepubCFI string) (*string, *string) { epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) if err != nil { log.Printf("Bookhoard: KEPUB→CFI failed to resolve EPUB path: %v", err) return nil, nil } if epubPath == "" { log.Printf("Bookhoard: KEPUB→CFI resolved empty EPUB path for %s", mediaItem.FilePath) return nil, nil } kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{ MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true}, FormatType: "kepub", }) if err != nil || !kepubFormat.FilePath.Valid { return nil, nil } converter := wsync.NewKEPUBCFIConverter(epubPath, kepubFormat.FilePath.String) result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.0, "") if err != nil { log.Printf("Bookhoard: KEPUB→CFI conversion error: %v", err) return nil, nil } var cfi *string if result.CFI != "" { cfi = &result.CFI log.Printf("Bookhoard: KEPUB→CFI converted (precision=%s)", result.Precision) } var ctx *string if result.ExtractedContext != "" { ctx = &result.ExtractedContext } return cfi, ctx }