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
1014 lines
33 KiB
Go
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
|
|
}
|