The 30-day retention window for soft-deleted annotations was a package const; move it behind the registry so it can be tuned live. annotations.go: - AnnotationService gains an optional *database.SettingsRegistry and a tombstoneTTL() helper. The skip-resurrect checks and the purge cutoff now call it instead of reading the TombstoneTTL const directly. - Add ActiveTombstoneTTL() so callers outside the sync package can compute cutoffs consistently with the service. - The package-level TombstoneTTL const is retained as the fallback for tests / unwired code paths. kobo.go, koreader.go: - The per-book tombstone sweep cutoff now uses h.annotationSvc.ActiveTombstoneTTL() instead of the wsync.TombstoneTTL const, so both the service and the handlers honor the configured TTL.
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(-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
|
|
}
|