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/v4" ) 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 // Phase 6: 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" } case "last-read-place", "reading-position": if bookmarkSync.BookmarkId != "" { // Extract position data from BookmarkId var epubcfi, chapter 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, 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}, }) 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 { 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 { // Phase 6: Use ContentId mapping with fallback logic bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, bookmarkSync.ContentId, deviceUUID) if err != 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 { // 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 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 { // 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 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, }) }