package handlers import ( "bookmann/internal/database" wsync "bookmann/internal/sync" "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 connManager *wsync.ConnectionManager } func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KOReaderHandler { return &KOReaderHandler{db: db, connManager: connManager} } 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}, }) if err == nil { // Broadcast progress update to all connected WebSocket clients deviceInfo := book.DeviceInfo if deviceInfo.DeviceModel == "" { deviceInfo.DeviceModel = "KOReader Device" } h.connManager.BroadcastProgressUpdate( uuid.UUID(mediaItemID.Bytes), book.Percentage, wsync.SourceDevice{ ID: uuid.UUID(userID.Bytes).String(), Name: deviceInfo.DeviceModel, Type: "koreader", }, ) } 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), }) }