feat(sync): Add Kobo/Koreader sync and conflict handling

- Add Kobo markup/sync endpoints for bookshelves
- Add Koreader progress sync with SHA-256 support
- Add sync conflict detection and resolution
- Update ebook scanner for better file matching
This commit is contained in:
2026-01-31 22:32:09 -05:00
parent 63008001c6
commit d28002e4cb
4 changed files with 728 additions and 134 deletions
+236 -32
View File
@@ -3,9 +3,12 @@ package handlers
import (
"bookmann/internal/database"
wsync "bookmann/internal/sync"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"regexp"
"time"
"github.com/google/uuid"
@@ -22,6 +25,161 @@ func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager)
return &KoboHandler{db: db, connManager: connManager}
}
// mapContentIdToBookmannUUID maps Kobo ContentId to Bookmann UUID with multiple fallback strategies
// Phase 6: Enhanced Kobo Sync - ContentId Mapping Logic
func (h *KoboHandler) mapContentIdToBookmannUUID(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 Bookmann UUID
return uuid.UUID(catalog.BookmannUuid.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},
BookmannUuid: 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},
BookmannUuid: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
KoboContentID: contentId,
ContentIDType: pgtype.Text{String: "bookmann_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"
}
// mapBookmannUUIDToKoboContentId maps Bookmann UUID to Kobo ContentId
// Creates new entry in device_catalogs if not exists
func (h *KoboHandler) mapBookmannUUIDToKoboContentId(c echo.Context, bookmannUUID uuid.UUID, deviceID uuid.UUID) (string, error) {
// Check if catalog entry already exists
catalog, err := h.db.GetDeviceCatalogByBookmannUUID(c.Request().Context(), database.GetDeviceCatalogByBookmannUUIDParams{
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
BookmannUuid: pgtype.UUID{Bytes: bookmannUUID, 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: bookmannUUID, 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 := "bookmann_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: bookmannUUID, Valid: true},
BookmannUuid: pgtype.UUID{Bytes: bookmannUUID, 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, bookmannUUID 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: bookmannUUID, 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"`
@@ -55,18 +213,20 @@ type KoboMarkupRequest struct {
}
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"`
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"`
BookmannUUID string `json:"BookmannUUID,omitempty"`
}
type KoboLibraryResponse struct {
@@ -110,8 +270,10 @@ type KoboAnalyticsTest struct {
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 {
@@ -122,6 +284,8 @@ func (h *KoboHandler) Initialization(c echo.Context) error {
librarySync := []KoboLibraryBook{}
for _, item := range mediaItems {
bookmannUUID := 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,
@@ -154,10 +318,15 @@ func (h *KoboHandler) Initialization(c echo.Context) error {
author = item.Author.String
}
entitlementId := item.EntitlementID.String
if entitlementId == "" || !item.EntitlementID.Valid {
entitlementId = "kobo_" + uuid.UUID(item.ID.Bytes).String()
// Phase 6: Use ContentId mapping instead of direct UUID
koboContentId, err := h.mapBookmannUUIDToKoboContentId(c, bookmannUUID, 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_" + bookmannUUID.String()
}
}
mimeType := item.MimeType.String
@@ -175,8 +344,11 @@ func (h *KoboHandler) Initialization(c echo.Context) error {
revision = int(item.RevisionNumber.Int32)
}
// Phase 6: Get collection metadata for this book
collections, _ := h.getCollectionMetadataForBook(c, bookmannUUID, deviceUUID)
librarySync = append(librarySync, KoboLibraryBook{
ContentId: uuid.UUID(item.ID.Bytes).String(),
ContentId: koboContentId,
ContentType: "6",
Title: item.Title,
Author: author,
@@ -184,10 +356,12 @@ func (h *KoboHandler) Initialization(c echo.Context) error {
PagesRemaining: pagesRemaining,
BookmarkCount: bookmarkCount,
LastModified: lastModified,
EntitlementId: entitlementId,
EntitlementId: koboContentId,
Revision: revision,
MimeType: mimeType,
FileSize: fileSize,
Categories: collections,
BookmannUUID: bookmannUUID.String(),
})
}
@@ -205,8 +379,10 @@ func (h *KoboHandler) LibrarySync(c echo.Context) error {
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 {
@@ -217,14 +393,19 @@ func (h *KoboHandler) Markup(c echo.Context) error {
markupsSynced := 0
bookmarksSynced := 0
unlinkedBooks := 0
for _, readingSync := range req.ReadingSync {
mediaUUID, err := uuid.Parse(readingSync.ContentId)
// Phase 6: Use ContentId mapping with fallback logic
bookmannUUID, err, _ := h.mapContentIdToBookmannUUID(c, readingSync.ContentId, deviceUUID)
if err != nil {
// Unlinked book detected
unlinkedBooks++
// TODO: Create unlinked book entry for manual resolution
continue
}
pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
pgMediaUUID := pgtype.UUID{Bytes: bookmannUUID, Valid: true}
percentage := readingSync.PercentRead / 100.0
@@ -240,7 +421,7 @@ func (h *KoboHandler) Markup(c echo.Context) error {
markupsSynced++
h.connManager.BroadcastProgressUpdate(
mediaUUID,
bookmannUUID,
percentage,
wsync.SourceDevice{
ID: uuid.UUID(userID).String(),
@@ -252,12 +433,14 @@ func (h *KoboHandler) Markup(c echo.Context) error {
}
for _, bookmarkSync := range req.BookmarkSync {
mediaUUID, err := uuid.Parse(bookmarkSync.ContentId)
// Phase 6: Use ContentId mapping with fallback logic
bookmannUUID, err, _ := h.mapContentIdToBookmannUUID(c, bookmarkSync.ContentId, deviceUUID)
if err != nil {
// Unlinked book - skip
continue
}
pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
pgMediaUUID := pgtype.UUID{Bytes: bookmannUUID, Valid: true}
switch bookmarkSync.BookmarkType {
case "annotation":
@@ -292,18 +475,29 @@ func (h *KoboHandler) Markup(c echo.Context) error {
})
}
return c.JSON(http.StatusOK, KoboSyncStatus{
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"`
@@ -317,12 +511,14 @@ func (h *KoboHandler) Bookmark(c echo.Context) error {
bookmarksSynced := 0
for _, bookmarkSync := range req.BookmarkSync {
mediaUUID, err := uuid.Parse(bookmarkSync.ContentId)
// Phase 6: Use ContentId mapping with fallback logic
bookmannUUID, err, _ := h.mapContentIdToBookmannUUID(c, bookmarkSync.ContentId, deviceUUID)
if err != nil {
// Unlinked book - skip
continue
}
pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
pgMediaUUID := pgtype.UUID{Bytes: bookmannUUID, Valid: true}
switch bookmarkSync.BookmarkType {
case "annotation":
@@ -367,8 +563,10 @@ func (h *KoboHandler) Bookmark(c echo.Context) error {
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 {
@@ -378,12 +576,14 @@ func (h *KoboHandler) AnalyticsGettests(c echo.Context) error {
}
for _, test := range req {
mediaUUID, err := uuid.Parse(test.ContentId)
// Phase 6: Use ContentId mapping with fallback logic
bookmannUUID, err, _ := h.mapContentIdToBookmannUUID(c, test.ContentId, deviceUUID)
if err != nil {
// Unlinked book - skip
continue
}
pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
pgMediaUUID := pgtype.UUID{Bytes: bookmannUUID, Valid: true}
percentage := test.PercentRead / 100.0
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
@@ -396,7 +596,7 @@ func (h *KoboHandler) AnalyticsGettests(c echo.Context) error {
if err == nil {
h.connManager.BroadcastProgressUpdate(
mediaUUID,
bookmannUUID,
percentage,
wsync.SourceDevice{
ID: uuid.UUID(userID).String(),
@@ -436,8 +636,10 @@ func parseKoboDeviceHeader(c echo.Context) (KoboDeviceInfo, error) {
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 {
@@ -451,12 +653,14 @@ func (h *KoboHandler) SyncFromServer(c echo.Context) error {
highlightsSent := 0
for _, syncData := range req {
mediaUUID, err := uuid.Parse(syncData.ContentId)
// Phase 6: Use ContentId mapping with fallback logic
bookmannUUID, err, _ := h.mapContentIdToBookmannUUID(c, syncData.ContentId, deviceUUID)
if err != nil {
// Unlinked book - skip
continue
}
pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
pgMediaUUID := pgtype.UUID{Bytes: bookmannUUID, Valid: true}
percentage := syncData.PercentRead / 100.0