Files
bookhoard/internal/handlers/kobo.go
T

523 lines
15 KiB
Go

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,
})
}