351 lines
11 KiB
Go
351 lines
11 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/v4"
|
|
)
|
|
|
|
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
|
|
// Phase 6: 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"
|
|
}
|
|
case "last-read-place", "reading-position":
|
|
if bookmarkSync.BookmarkId != "" {
|
|
// Extract position data from BookmarkId
|
|
var epubcfi, chapter string
|
|
|
|
if strings.HasPrefix(bookmarkSync.BookmarkId, "epubcfi(") {
|
|
epubcfi = strings.TrimPrefix(bookmarkSync.BookmarkId, "epubcfi(")
|
|
epubcfi = strings.TrimSuffix(epubcfi, ")")
|
|
}
|
|
|
|
// Update reading_progress with precise position
|
|
_, 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},
|
|
DeviceSyncData: pgtype.JSONB{
|
|
Bytes: []byte(fmt.Sprintf(`{"kobo_bookmark_id": "%s", "hidden": %v}`,
|
|
bookmarkSync.BookmarkId, bookmarkSync.Hidden)),
|
|
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++
|
|
}
|
|
default:
|
|
fmt.Printf("Unknown bookmark type: %s", bookmarkSync.BookmarkType)
|
|
}
|
|
}
|
|
default:
|
|
fmt.Printf("Unknown bookmark type: %s", bookmarkSync.BookmarkType)
|
|
}
|
|
|
|
_, 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 {
|
|
// Phase 6: Use ContentId mapping with fallback logic
|
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, bookmarkSync.ContentId, deviceUUID)
|
|
if err != 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 {
|
|
// Phase 6: Use ContentId mapping with fallback logic
|
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
|
|
if err != nil || bookhoardUUID == uuid.Nil {
|
|
// Unlinked book or invalid UUID - 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 {
|
|
// Phase 6: Use ContentId mapping with fallback logic
|
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
|
|
if err != nil || bookhoardUUID == uuid.Nil {
|
|
// Unlinked book or invalid UUID - 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,
|
|
})
|
|
}
|