diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go new file mode 100644 index 0000000..c1b0a0b --- /dev/null +++ b/internal/handlers/koreader.go @@ -0,0 +1,566 @@ +package handlers + +import ( + "bookmann/internal/database" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +type KOReaderHandler struct { + db *database.Queries +} + +func NewKOReaderHandler(db *database.Queries) *KOReaderHandler { + return &KOReaderHandler{db: db} +} + +type KOReaderProgressRequest struct { + LibraryID *string `json:"library_id,omitempty"` + Books []KOReaderBookProgress `json:"books" validate:"required"` + SyncMode string `json:"sync_mode,omitempty"` +} + +type KOReaderBookProgress struct { + UUID string `json:"uuid,omitempty"` + Title string `json:"title,omitempty"` + Authors []string `json:"authors,omitempty"` + Progress float64 `json:"progress"` + Percentage float64 `json:"percentage"` + LastRead string `json:"last_read,omitempty"` + FilePath string `json:"file_path,omitempty"` + DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"` + Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` + Highlights []KOReaderHighlight `json:"highlights,omitempty"` + Notes []KOReaderNote `json:"notes,omitempty"` + Chapter *int `json:"chapter,omitempty"` + Character *int64 `json:"character,omitempty"` + Epubcfi *string `json:"epubcfi,omitempty"` + Page *int `json:"page,omitempty"` + TotalPages *int `json:"total_pages,omitempty"` +} + +type KOReaderDeviceInfo struct { + KOReaderVersion string `json:"koreader_version,omitempty"` + DeviceModel string `json:"device_model,omitempty"` +} + +type KOReaderBookmark struct { + Chapter int `json:"chapter,omitempty"` + Datetime string `json:"datetime,omitempty"` + Notes string `json:"notes,omitempty"` + Pos0 string `json:"pos0,omitempty"` + Pos1 string `json:"pos1,omitempty"` + Page int `json:"page,omitempty"` + Text string `json:"text,omitempty"` + Type string `json:"type,omitempty"` + Percentage *float64 `json:"percentage,omitempty"` +} + +type KOReaderHighlight struct { + Chapter int `json:"chapter,omitempty"` + Datetime string `json:"datetime,omitempty"` + Notes string `json:"notes,omitempty"` + Pos0 string `json:"pos0,omitempty"` + Pos1 string `json:"pos1,omitempty"` + Page int `json:"page,omitempty"` + Text string `json:"text,omitempty"` + Type string `json:"type,omitempty"` + Color string `json:"color,omitempty"` + Percentage *float64 `json:"percentage,omitempty"` +} + +type KOReaderNote struct { + Chapter int `json:"chapter,omitempty"` + Datetime string `json:"datetime,omitempty"` + Notes string `json:"notes,omitempty"` + Pos0 string `json:"pos0,omitempty"` + Pos1 string `json:"pos1,omitempty"` + Page int `json:"page,omitempty"` + Text string `json:"text,omitempty"` + Type string `json:"type,omitempty"` + Percentage *float64 `json:"percentage,omitempty"` +} + +type KOReaderSyncResponse struct { + SyncStatus string `json:"sync_status"` + BooksSynced int `json:"books_synced"` + Conflicts []KOReaderConflict `json:"conflicts,omitempty"` + Timestamp string `json:"timestamp"` + DeviceUpdated bool `json:"device_updated"` +} + +type KOReaderConflict struct { + BookUUID string `json:"book_uuid"` + ConflictType string `json:"conflict_type"` + DeviceProgress float64 `json:"device_progress"` + ServerProgress float64 `json:"server_progress"` + Resolution string `json:"resolution"` +} + +type KOReaderMetadata struct { + UUID string `json:"uuid"` + Title string `json:"title"` + Authors []string `json:"authors"` + Progress KOReaderProgressData `json:"progress"` + Annotations KOReaderAnnotations `json:"annotations"` + LastSync string `json:"last_sync"` +} + +type KOReaderProgressData struct { + Percentage float64 `json:"percentage"` + Character *int64 `json:"character,omitempty"` + Epubcfi *string `json:"epubcfi,omitempty"` + Chapter *int `json:"chapter,omitempty"` + ChapterProgress *float64 `json:"chapter_progress,omitempty"` + Page *int `json:"page,omitempty"` + TotalPages *int `json:"total_pages,omitempty"` +} + +type KOReaderAnnotations struct { + Highlights []KOReaderHighlight `json:"highlights,omitempty"` + Notes []KOReaderNote `json:"notes,omitempty"` + Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` +} + +type KOReaderLibraryResponse struct { + LibrarySync []KOReaderLibraryBook `json:"library_sync"` + TotalBooks int `json:"total_books"` + LastSync string `json:"last_sync"` +} + +type KOReaderLibraryBook struct { + UUID string `json:"uuid"` + Title string `json:"title"` + Author string `json:"author"` + ContentType string `json:"content_type"` + PercentRead float64 `json:"percent_read"` + PagesRemaining *int `json:"pages_remaining,omitempty"` + BookmarkCount int `json:"bookmark_count"` + LastModified string `json:"last_modified"` +} + +func (h *KOReaderHandler) SyncProgress(c echo.Context) error { + device := c.Get("device").(database.Devices) + userID := device.UserID.Bytes + + var req KOReaderProgressRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid request format", + "details": err.Error(), + }) + } + + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": err.Error(), + }) + } + + pgUserID := pgtype.UUID{Bytes: userID, Valid: true} + + booksSynced := 0 + conflicts := []KOReaderConflict{} + + for _, book := range req.Books { + var mediaUUID uuid.UUID + var err error + + if book.UUID != "" { + mediaUUID, err = uuid.Parse(book.UUID) + if err != nil { + continue + } + + mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) + if err == nil { + err = h.updateProgressForBook(c, pgUserID, mediaItem.ID, book) + if err == nil { + booksSynced++ + } + } + } else if book.FilePath != "" { + mediaItem, err := h.db.GetMediaItemByFilePath(c.Request().Context(), book.FilePath) + if err == nil { + err = h.updateProgressForBook(c, pgUserID, mediaItem.ID, book) + if err == nil { + booksSynced++ + } + } + } else if book.Title != "" { + mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{ + Limit: 100, + Offset: 0, + }) + if err == nil { + for _, mi := range mediaItems { + if mi.Title == book.Title && (book.Authors == nil || mi.Author.String == book.Authors[0]) { + err = h.updateProgressForBook(c, pgUserID, mi.ID, book) + if err == nil { + booksSynced++ + } + break + } + } + } + } + } + + _, 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", + }) + } + + if req.SyncMode == "immediate" { + return c.JSON(http.StatusAccepted, KOReaderSyncResponse{ + SyncStatus: "accepted", + BooksSynced: booksSynced, + Conflicts: conflicts, + Timestamp: time.Now().Format(time.RFC3339), + DeviceUpdated: true, + }) + } + + return c.JSON(http.StatusOK, KOReaderSyncResponse{ + SyncStatus: "completed", + BooksSynced: booksSynced, + Conflicts: conflicts, + Timestamp: time.Now().Format(time.RFC3339), + DeviceUpdated: true, + }) +} + +func (h *KOReaderHandler) updateProgressForBook(c echo.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error { + var epubcfi pgtype.Text + var chapter pgtype.Int4 + var characterOffset pgtype.Int8 + var currentPage pgtype.Int4 + var totalPages pgtype.Int4 + + if book.Epubcfi != nil { + epubcfi = pgtype.Text{String: *book.Epubcfi, Valid: true} + } + if book.Chapter != nil { + chapter = pgtype.Int4{Int32: int32(*book.Chapter), Valid: true} + } + if book.Character != nil { + characterOffset = pgtype.Int8{Int64: *book.Character, Valid: true} + } + if book.Page != nil { + currentPage = pgtype.Int4{Int32: int32(*book.Page), Valid: true} + } + if book.TotalPages != nil { + totalPages = pgtype.Int4{Int32: int32(*book.TotalPages), Valid: true} + } + + _, err := h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ + MediaItemID: mediaItemID, + UserID: userID, + Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true}, + Epubcfi: epubcfi, + Chapter: chapter, + ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true}, + CharacterOffset: characterOffset, + CurrentPage: currentPage, + TotalPages: totalPages, + LastSyncDevice: pgtype.Text{String: "koreader", Valid: true}, + LastSyncSource: pgtype.Text{String: "koreader", Valid: true}, + }) + + return err +} + +func (h *KOReaderHandler) GetMetadata(c echo.Context) error { + device := c.Get("device").(database.Devices) + userID := device.UserID.Bytes + + bookUUID, err := uuid.Parse(c.Param("uuid")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid book UUID", + }) + } + + pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true} + pgUserID := pgtype.UUID{Bytes: userID, Valid: true} + + mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{ + "error": "book not found", + }) + } + + progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{ + MediaItemID: pgBookUUID, + UserID: pgUserID, + }) + + if err != nil { + return c.JSON(http.StatusOK, map[string]interface{}{ + "uuid": bookUUID, + "title": mediaItem.Title, + "author": mediaItem.Author, + "progress": nil, + "annotations": map[string][]interface{}{}, + }) + } + + progressData := KOReaderProgressData{ + Percentage: progress.Percentage.Float64, + } + + if progress.Epubcfi.Valid { + progressData.Epubcfi = &progress.Epubcfi.String + } + if progress.Chapter.Valid { + ch := int(progress.Chapter.Int32) + progressData.Chapter = &ch + } + if progress.ChapterProgress.Valid { + cp := progress.ChapterProgress.Float64 + progressData.ChapterProgress = &cp + } + if progress.CharacterOffset.Valid { + co := int64(progress.CharacterOffset.Int64) + progressData.Character = &co + } + if progress.CurrentPage.Valid { + cp := int(progress.CurrentPage.Int32) + progressData.Page = &cp + } + if progress.TotalPages.Valid { + tp := int(progress.TotalPages.Int32) + progressData.TotalPages = &tp + } + + annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ + MediaItemID: pgBookUUID, + UserID: pgUserID, + }) + + annotationsResponse := KOReaderAnnotations{ + Highlights: []KOReaderHighlight{}, + Notes: []KOReaderNote{}, + Bookmarks: []KOReaderBookmark{}, + } + + for _, ann := range annotations { + if ann.AnnotationType == "highlight" { + annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{ + Text: ann.SelectionText, + Pos0: ann.StartPosition.String, + Pos1: ann.EndPosition.String, + Color: ann.Color.String, + Datetime: ann.CreatedAt.Time.Format(time.RFC3339), + }) + } else if ann.AnnotationType == "note" { + annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{ + Text: ann.SelectionText, + Pos0: ann.StartPosition.String, + Datetime: ann.CreatedAt.Time.Format(time.RFC3339), + }) + } + } + + lastSync := "never" + if progress.LastSyncTimestamp.Valid { + lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339) + } + + metadata := KOReaderMetadata{ + UUID: bookUUID.String(), + Title: mediaItem.Title, + Authors: []string{mediaItem.Author.String}, + Progress: progressData, + Annotations: annotationsResponse, + LastSync: lastSync, + } + + return c.JSON(http.StatusOK, metadata) +} + +func (h *KOReaderHandler) GetLibrary(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", + }) + } + + libraryBooks := []KOReaderLibraryBook{} + + for _, item := range mediaItems { + pgItemUUID := pgtype.UUID{Bytes: item.ID.Bytes, Valid: true} + + progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{ + MediaItemID: pgItemUUID, + UserID: pgUserID, + }) + + percentRead := 0.0 + var pagesRemaining *int + bookmarkCount := 0 + lastModified := time.Now().Format(time.RFC3339) + + if err == nil { + percentRead = progress.Percentage.Float64 + if progress.TotalPages.Valid && progress.CurrentPage.Valid { + remaining := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32) + pagesRemaining = &remaining + } + if progress.LastReadAt.Valid { + lastModified = progress.LastReadAt.Time.Format(time.RFC3339) + } + } + + annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ + MediaItemID: pgItemUUID, + UserID: pgUserID, + }) + bookmarkCount = len(annotations) + + libraryBooks = append(libraryBooks, KOReaderLibraryBook{ + UUID: uuid.UUID(item.ID.Bytes).String(), + Title: item.Title, + Author: item.Author.String, + ContentType: "6", + PercentRead: percentRead * 100, + PagesRemaining: pagesRemaining, + BookmarkCount: bookmarkCount, + LastModified: lastModified, + }) + } + + return c.JSON(http.StatusOK, KOReaderLibraryResponse{ + LibrarySync: libraryBooks, + TotalBooks: len(libraryBooks), + LastSync: time.Now().Format(time.RFC3339), + }) +} + +func (h *KOReaderHandler) SyncBookmarks(c echo.Context) error { + device := c.Get("device").(database.Devices) + userID := device.UserID.Bytes + + var req struct { + BookUUID string `json:"book_uuid" validate:"required"` + Bookmarks []KOReaderBookmark `json:"bookmarks"` + Notes []KOReaderNote `json:"notes"` + Highlights []KOReaderHighlight `json:"highlights"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid request format", + }) + } + + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": err.Error(), + }) + } + + bookUUID, err := uuid.Parse(req.BookUUID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid book UUID", + }) + } + + pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true} + pgUserID := pgtype.UUID{Bytes: userID, Valid: true} + + bookmarksSynced := 0 + notesSynced := 0 + highlightsSynced := 0 + + for _, bookmark := range req.Bookmarks { + position := "" + if bookmark.Pos0 != "" { + position = bookmark.Pos0 + } else if bookmark.Page > 0 { + position = fmt.Sprintf("page:%d", bookmark.Page) + } + + _, err := h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ + MediaItemID: pgBookUUID, + UserID: pgUserID, + Content: bookmark.Text, + Position: pgtype.Text{String: position, Valid: position != ""}, + }) + + if err == nil { + bookmarksSynced++ + } + } + + for _, note := range req.Notes { + position := "" + if note.Pos0 != "" { + position = note.Pos0 + } else if note.Page > 0 { + position = fmt.Sprintf("page:%d", note.Page) + } + + _, err := h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ + MediaItemID: pgBookUUID, + UserID: pgUserID, + Content: note.Notes, + Position: pgtype.Text{String: position, Valid: position != ""}, + }) + + if err == nil { + notesSynced++ + } + } + + for _, highlight := range req.Highlights { + startPos := highlight.Pos0 + endPos := highlight.Pos1 + if startPos == "" && highlight.Page > 0 { + startPos = fmt.Sprintf("page:%d", highlight.Page) + endPos = startPos + } + + color := "#ffff00" + if highlight.Color != "" { + color = highlight.Color + } + + _, err := h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ + MediaItemID: pgBookUUID, + UserID: pgUserID, + SelectionText: highlight.Text, + StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""}, + EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""}, + Color: pgtype.Text{String: color, Valid: true}, + }) + + if err == nil { + highlightsSynced++ + } + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "sync_status": "completed", + "bookmarks_synced": bookmarksSynced, + "notes_synced": notesSynced, + "highlights_synced": highlightsSynced, + "total_synced": bookmarksSynced + notesSynced + highlightsSynced, + "timestamp": time.Now().Format(time.RFC3339), + }) +}