The platform had three duplicated, divergent book resolvers (koreader, kobo, BookMatchingService) and none of them consulted media_item_formats.file_sha256 - per-format hashes for converted files (KEPUB, PDF) are computed and stored at import/conversion time but were never used for lookup. GetMediaItemFormatBySHA256 existed with zero callers. Any client holding a converted file could never match by hash. Add internal/services/book_resolver.go: a single shared resolution path from client-supplied identifier to media_item. ResolveBySHA256 checks media_items.file_sha256 first (indexed GetMediaItemBySHA256), then falls back to media_item_formats. file_sha256 (indexed GetMediaItemFormatBySHA256, first caller) so a converted format matches with equal confidence. The import-time SHA-256 is the canonical identifier shared by every interface. Wire two of the existing resolvers through it: - BookMatchingService.matchBySHA256: replaces the in-memory ListMediaItems scan of up to 1000 rows with the resolver's indexed lookups, and gains format awareness for the link/auto-link UI. MatchMethod now reports sha256_sha256 or sha256_sha256_format - KoboHandler.mapContentIdToBookhoardUUID: the SHA-256 heuristic branch (ContentId that looks like a 64-char hash) now resolves format-aware too. Kobo's entitlement_id wire identity is untouched; only the opportunistic hash branch changed
1017 lines
34 KiB
Go
1017 lines
34 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
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
|
|
bookResolver *services.BookResolver
|
|
}
|
|
|
|
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
|
return &KoboHandler{db: db, connManager: connManager, bookResolver: services.NewBookResolver(db)}
|
|
}
|
|
|
|
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 (format-aware: also checks
|
|
// media_item_formats, so a converted/alternate format hash matches).
|
|
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx.Request().Context(), contentId)
|
|
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(-h.annotationSvc.ActiveTombstoneTTL()), 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
|
|
}
|