package handlers import ( "bookmann/internal/database" wsync "bookmann/internal/sync" "encoding/json" "fmt" "net/http" "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} } 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"` } 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"` } 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 pgUserID := pgtype.UUID{Bytes: userID, Valid: true} 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 { 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 } entitlementId := item.EntitlementID.String if entitlementId == "" || !item.EntitlementID.Valid { entitlementId = "kobo_" + uuid.UUID(item.ID.Bytes).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) } librarySync = append(librarySync, KoboLibraryBook{ ContentId: uuid.UUID(item.ID.Bytes).String(), ContentType: "6", Title: item.Title, Author: author, PercentRead: percentRead, PagesRemaining: pagesRemaining, BookmarkCount: bookmarkCount, LastModified: lastModified, EntitlementId: entitlementId, Revision: revision, MimeType: mimeType, FileSize: fileSize, }) } 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 pgUserID := pgtype.UUID{Bytes: userID, Valid: true} 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 for _, readingSync := range req.ReadingSync { mediaUUID, err := uuid.Parse(readingSync.ContentId) if err != nil { continue } pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, 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( mediaUUID, percentage, wsync.SourceDevice{ ID: uuid.UUID(userID).String(), Name: device.DeviceName, Type: "kobo", }, ) } } for _, bookmarkSync := range req.BookmarkSync { mediaUUID, err := uuid.Parse(bookmarkSync.ContentId) if err != nil { continue } pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, 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", MarkupsSynced: markupsSynced, BookmarksSynced: bookmarksSynced, }) } func (h *KoboHandler) Bookmark(c echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes pgUserID := pgtype.UUID{Bytes: userID, Valid: true} 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 { mediaUUID, err := uuid.Parse(bookmarkSync.ContentId) if err != nil { continue } pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, 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 pgUserID := pgtype.UUID{Bytes: userID, Valid: true} 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 { mediaUUID, err := uuid.Parse(test.ContentId) if err != nil { continue } pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, 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( mediaUUID, 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 pgUserID := pgtype.UUID{Bytes: userID, Valid: true} 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 { mediaUUID, err := uuid.Parse(syncData.ContentId) if err != nil { continue } pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, 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: "bookmann", 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, }) }