523 lines
15 KiB
Go
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,
|
|
})
|
|
}
|