Update all handler functions to use *echo.Context (pointer) instead of echo.Context (value) as required by Echo v5. Changes across all handler files: - analytics.go: Update handler signatures - auth.go: Update authentication handler signatures - book_matching.go: Update matching handler signatures - collections.go: Update collection handler signatures - collections_preview_test.go: Update test signatures - commonhandlers.go: Update common handler signatures - conflicts.go: Update conflict handler signatures - context.go: Update context handler signatures - dashboard.go: Update dashboard handler signatures - devices.go: Update device handler signatures - jobs.go: Update job handler signatures - kobo.go: Update Kobo handler signatures - koreader.go: Update Koreader handler signatures - library.go: Update library handler signatures - matching.go: Update matching handler signatures - media.go: Update media handler signatures - opds.go: Update OPDS handler signatures - progress.go: Update progress handler signatures - queue.go: Update queue handler signatures - refresh_token.go: Update token handler signatures - scanner.go: Update scanner handler signatures - sidecar.go: Update sidecar handler signatures - sync.go: Update sync handler signatures - system_settings.go: Update settings handler signatures - websocket.go: Update WebSocket handler signatures All handlers now properly implement Echo v5's pointer-based context pattern. This change is necessary for type safety and compatibility with Echo v5's improved context handling and WebSocket support.
757 lines
24 KiB
Go
757 lines
24 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
wsync "bookhoard/internal/sync"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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
|
|
}
|
|
|
|
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
|
return &KoboHandler{db: db, connManager: connManager}
|
|
}
|
|
|
|
// 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 uuid.UUID(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 uuid.UUID(mediaItem.ID.Bytes), nil, "sha256_match"
|
|
}
|
|
}
|
|
|
|
// Step 3: Try to parse as UUID directly
|
|
if parsedUUID, err := uuid.Parse(contentId); err == nil {
|
|
// Check if this UUID exists in media_items
|
|
mediaItem, err := h.db.GetMediaItem(ctx.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
|
if err == nil {
|
|
// Create device catalog entry
|
|
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
|
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
|
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
|
|
}
|
|
|
|
// calculateFileSHA256 calculates SHA-256 hash of file path
|
|
func calculateFileSHA256(filePath string) string {
|
|
hash := sha256.Sum256([]byte(filePath))
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
type KoboDeviceInfo struct {
|
|
DeviceID string `json:"DeviceId"`
|
|
Model string `json:"Model"`
|
|
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"`
|
|
}
|
|
|
|
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.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
|
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
|
|
|
|
for _, readingSync := range req.ReadingSync {
|
|
// Use ContentId mapping with fallback logic
|
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
|
if err != nil || bookhoardUUID == uuid.Nil {
|
|
// Unlinked book detected
|
|
unlinkedBooks++
|
|
// TODO: Create unlinked book entry for manual resolution
|
|
continue
|
|
}
|
|
|
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
|
|
|
percentage := readingSync.PercentRead / 100.0
|
|
|
|
_, 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++
|
|
|
|
h.connManager.BroadcastProgressUpdate(
|
|
bookhoardUUID,
|
|
percentage,
|
|
wsync.SourceDevice{
|
|
ID: uuid.UUID(userID).String(),
|
|
Name: device.DeviceName,
|
|
Type: "kobo",
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
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 != "" {
|
|
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 != "" {
|
|
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, ")")
|
|
}
|
|
|
|
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
|
MediaItemID: pgMediaUUID,
|
|
UserID: pgUserID,
|
|
Epubcfi: pgtype.Text{String: epubcfi, Valid: true},
|
|
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,
|
|
}
|
|
|
|
// 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 != "" {
|
|
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 != "" {
|
|
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 {
|
|
// Use ContentId mapping with fallback logic
|
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
|
|
if err != nil || bookhoardUUID == uuid.Nil {
|
|
// Unlinked book - skip
|
|
continue
|
|
}
|
|
|
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
|
percentage := test.PercentRead / 100.0
|
|
|
|
_, 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 {
|
|
h.connManager.BroadcastProgressUpdate(
|
|
bookhoardUUID,
|
|
percentage,
|
|
wsync.SourceDevice{
|
|
ID: uuid.UUID(userID).String(),
|
|
Name: device.DeviceName,
|
|
Type: "kobo",
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
_, 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 parseKoboDeviceHeader(c *echo.Context) (KoboDeviceInfo, error) {
|
|
deviceHeader := c.Request().Header.Get("x-kobo-device")
|
|
if deviceHeader == "" {
|
|
return KoboDeviceInfo{}, fmt.Errorf("missing x-kobo-device header")
|
|
}
|
|
|
|
var device KoboDeviceInfo
|
|
if err := json.Unmarshal([]byte(deviceHeader), &device); err != nil {
|
|
return KoboDeviceInfo{}, fmt.Errorf("invalid device header format")
|
|
}
|
|
|
|
return device, nil
|
|
}
|
|
|
|
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 {
|
|
// Use ContentId mapping with fallback logic
|
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
|
|
if err != nil || bookhoardUUID == uuid.Nil {
|
|
// Unlinked book - skip
|
|
continue
|
|
}
|
|
|
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
|
|
|
percentage := syncData.PercentRead / 100.0
|
|
|
|
_, 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" {
|
|
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" {
|
|
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 {
|
|
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,
|
|
})
|
|
}
|