diff --git a/internal/handlers/kobo.go b/internal/handlers/kobo.go index 9bd2f36..3872e0a 100644 --- a/internal/handlers/kobo.go +++ b/internal/handlers/kobo.go @@ -54,43 +54,449 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx echo.Context, contentId st }) return uuid.UUID(mediaItem.ID.Bytes), nil, "sha256_match" } - case "last-read-place", "reading-position": + } + + // 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 + } + + // Phase 6: 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" + } + + // Phase 6: 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 { + // Phase 6: 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 { + // Phase 6: 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 != "" { - // Extract position data from BookmarkId - var epubcfi, chapter string - + var epubcfi string + if strings.HasPrefix(bookmarkSync.BookmarkId, "epubcfi(") { epubcfi = strings.TrimPrefix(bookmarkSync.BookmarkId, "epubcfi(") epubcfi = strings.TrimSuffix(epubcfi, ")") } - - // Update reading_progress with precise position + _, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, + 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}, - DeviceSyncData: pgtype.JSONB{ - Bytes: []byte(fmt.Sprintf(`{"kobo_bookmark_id": "%s", "hidden": %v}`, - bookmarkSync.BookmarkId, bookmarkSync.Hidden)), - Valid: true, - }, - LastSyncDevice: pgtype.Text{String: "kobo", Valid: true}, - LastSyncSource: pgtype.Text{String: "kobo", 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++ } - default: - fmt.Printf("Unknown bookmark type: %s", bookmarkSync.BookmarkType) - } - } - default: - fmt.Printf("Unknown bookmark type: %s", bookmarkSync.BookmarkType) } + } _, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID) if err != nil { @@ -137,7 +543,7 @@ func (h *KoboHandler) Bookmark(c echo.Context) error { for _, bookmarkSync := range req.BookmarkSync { // Phase 6: Use ContentId mapping with fallback logic bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, bookmarkSync.ContentId, deviceUUID) - if err != nil { + if err != nil || bookhoardUUID == uuid.Nil { // Unlinked book - skip continue } @@ -203,7 +609,7 @@ func (h *KoboHandler) AnalyticsGettests(c echo.Context) error { // Phase 6: Use ContentId mapping with fallback logic bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID) if err != nil || bookhoardUUID == uuid.Nil { - // Unlinked book or invalid UUID - skip + // Unlinked book - skip continue } @@ -280,7 +686,7 @@ func (h *KoboHandler) SyncFromServer(c echo.Context) error { // Phase 6: Use ContentId mapping with fallback logic bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID) if err != nil || bookhoardUUID == uuid.Nil { - // Unlinked book or invalid UUID - skip + // Unlinked book - skip continue }