Files
bookhoard/internal/handlers/kobo.go
T
john-okeefe 75b33fdae6 feat(sync): wire annotation sync into all device and web handlers
Complete the annotation sync pipeline across all ingest and serve paths.
Previously, annotations sent inline with KOReader progress pushes were
silently discarded, and no annotations were ever served back to devices.

INGEST (device → server):

KOReader (koreader.go):
  - Add processBookAnnotations helper that processes inline highlights,
    notes, and bookmarks from every progress push (immediate + checkpoint)
  - Highlights get CRE→CFI position conversion before SaveHighlight
  - KOReader 'notes' (text + notes) stored as highlights with NoteText
    to ensure correct round-trip classification
  - Bookmarks routed through SaveBookmark with device sync data
  - Called from both updateProgressForBook and handleCheckpointSync

Kobo (kobo.go):
  - Markup handler: annotations and bookmarks route through
    AnnotationService (SaveHighlight/SaveBookmark)
  - Bookmark handler: same routing with device sync data
  - SyncFromServer handler: same routing
  - All handlers fall back to direct DB calls when annotationSvc == nil

Web reader (media.go):
  - CreateMediaHighlight → SaveHighlight (Source="web", ModifiedAt=now)
  - CreateMediaNote → SaveNote (Source="web")
  - DeleteMediaHighlight → TombstoneHighlightByID
  - DeleteMediaNote → TombstoneNoteByID (was hard delete, now tombstone)
  - All fall back to old behavior when annotationSvc == nil

SERVE (server → device):

KOReader GetMetadata (koreader.go):
  - Query and serve bookmarks from media_bookmarks table (was missing)
  - Serve deleted_highlights and deleted_bookmarks arrays containing
    device_sync_data + dedup_key for client-side deletion
  - Highlights/notes already served with reverse CFI conversion

Kobo Markup handler (kobo.go):
  - Track processed books during sync
  - Query tombstones per book, extract bookmark_id from device_sync_data
  - Return DeletedAnnotations array in KoboSyncStatus response

Conflict resolution (conflicts.go):
  - Enable annotation conflict types in ResolveConflict handler
  - Add applyAnnotationResolution dispatching to:
    applyHighlightResolution / applyBookmarkResolution / applyNoteResolution
  - Each looks up by dedup_key and applies winner's fields
  - Allow manual override of auto_resolved conflicts
    (changed check from != "unresolved" to == "user_resolved")

Infrastructure:
  - AnnotationService field + SetAnnotationService in router Config
  - Inject AnnotationService into KOReader, Kobo, Media handlers
  - Start tombstone purger goroutine in main.go (24h interval)
  - Test helpers: construct AnnotationService in test setup
2026-07-29 14:49:19 -04:00

1014 lines
33 KiB
Go

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(-wsync.TombstoneTTL), 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
}