From 6ddc551d6444c008f0f51c6938f6b309bbd9f8bf Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Jan 2026 16:11:11 -0500 Subject: [PATCH] Phase 1 Week 3: Core Progress APIs - Create internal/handlers/progress.go with universal progress endpoints - GET /api/progress/:id - Get progress with all location references - POST /api/progress/:id - Update progress with automatic conversion - GET /api/progress/:id/history - Get reading session history - Progress response includes: - format_group (reflowable, fixed_layout, comic_archive) - percentage (0.0-1.0) - location_references (page, epubcfi, chapter, character) - device_sync information - UpdateUniversalProgress accepts multiple input formats: - percentage directly - page/total_pages (auto-converts to percentage) - epubcfi for EPUBs - chapter/chapter_progress - Uses sync package for format conversion - Backward compatible with existing progress endpoints - Add routes to SetupRoutes in ebook.go - Fixes pgtype wrapper type access (.Float64, .Int32, .Int64) --- internal/handlers/ebook.go | 7 ++ internal/handlers/progress.go | 214 ++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 internal/handlers/progress.go diff --git a/internal/handlers/ebook.go b/internal/handlers/ebook.go index 1d165b7..22393c6 100644 --- a/internal/handlers/ebook.go +++ b/internal/handlers/ebook.go @@ -76,10 +76,17 @@ func SetupRoutes(g *echo.Group, db *database.Queries) *Handler { g.GET("/media-items/:id/rating", h.GetMediaRating) g.PUT("/media-items/:id/rating", h.UpdateMediaRating) g.DELETE("/media-items/:id/rating", h.DeleteMediaRating) + + // Legacy progress routes (deprecated - use universal progress instead) g.GET("/media-items/:id/progress", h.GetMediaReadingProgress) g.PUT("/media-items/:id/progress", h.UpdateMediaReadingProgress) g.DELETE("/media-items/:id/progress", h.DeleteMediaReadingProgress) + // Universal Progress routes (Phase 1) + g.GET("/progress/:id", h.GetUniversalProgress) + g.POST("/progress/:id", h.UpdateUniversalProgress) + g.GET("/progress/:id/history", h.GetProgressHistory) + // Notes routes g.GET("/media-items/:id/notes", h.GetMediaNotes) g.POST("/media-items/:id/notes", h.CreateMediaNote) diff --git a/internal/handlers/progress.go b/internal/handlers/progress.go new file mode 100644 index 0000000..5162f97 --- /dev/null +++ b/internal/handlers/progress.go @@ -0,0 +1,214 @@ +package handlers + +import ( + "bookmann/internal/database" + "bookmann/internal/sync" + "context" + "net/http" + "strconv" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +// GetUniversalProgress retrieves progress with all location references +func (h *Handler) GetUniversalProgress(c echo.Context) error { + user := MustGetAuthenticatedUser(c) + + mediaItemID, err := uuid.Parse(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID") + } + + progress, err := h.db.GetUniversalProgress(context.Background(), database.GetUniversalProgressParams{ + MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, + UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, + }) + if err != nil { + if err == pgx.ErrNoRows { + return c.JSON(http.StatusOK, map[string]interface{}{ + "media_item_id": mediaItemID, + "progress": nil, + }) + } + return echo.NewHTTPError(http.StatusInternalServerError, "failed to get progress") + } + + response := map[string]interface{}{ + "book_id": progress.MediaItemID.Bytes, + "format_group": progress.FormatGroup, + "universal_progress": map[string]interface{}{ + "percentage": progress.Percentage.Float64, + }, + "location_references": map[string]interface{}{ + "percentage": progress.Percentage.Float64, + "page": int(progress.CurrentPage.Int32), + "total_pages": int(progress.TotalPages.Int32), + }, + } + + if progress.Epubcfi.Valid { + response["location_references"].(map[string]interface{})["epubcfi"] = progress.Epubcfi.String + } + if progress.Chapter.Valid { + response["location_references"].(map[string]interface{})["chapter"] = int(progress.Chapter.Int32) + } + if progress.ChapterProgress.Valid { + response["location_references"].(map[string]interface{})["chapter_progress"] = progress.ChapterProgress.Float64 + } + if progress.CharacterOffset.Valid { + response["location_references"].(map[string]interface{})["character"] = int64(progress.CharacterOffset.Int64) + } + + deviceSync := map[string]interface{}{} + if progress.LastSyncDevice.Valid { + deviceSync["device"] = progress.LastSyncDevice.String + } + if progress.LastSyncSource.Valid { + deviceSync["source"] = progress.LastSyncSource.String + } + if progress.LastSyncTimestamp.Valid { + deviceSync["last_sync"] = progress.LastSyncTimestamp.Time + } + if len(deviceSync) > 0 { + response["device_sync"] = deviceSync + } + + return c.JSON(http.StatusOK, response) +} + +// UpdateUniversalProgress updates progress with automatic conversion +func (h *Handler) UpdateUniversalProgress(c echo.Context) error { + user := MustGetAuthenticatedUser(c) + + mediaItemID, err := uuid.Parse(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID") + } + + var req struct { + Source string `json:"source"` + Location struct { + Percentage *float64 `json:"percentage"` + Page *int `json:"page"` + TotalPages *int `json:"total_pages"` + Epubcfi *string `json:"epubcfi"` + Character *int64 `json:"character"` + Chapter *int `json:"chapter"` + ViewportY *float64 `json:"viewport_y"` + } `json:"location"` + DeviceMetadata struct { + DeviceType string `json:"device_type"` + UserAgent string `json:"user_agent"` + } `json:"device_metadata"` + } + + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") + } + + mediaItem, err := h.db.GetMediaItem(context.Background(), pgtype.UUID{Bytes: mediaItemID, Valid: true}) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, "media item not found") + } + + percentage := 0.0 + if req.Location.Percentage != nil { + percentage = *req.Location.Percentage + } else if req.Location.Page != nil && req.Location.TotalPages != nil { + percentage = sync.PageToPercentage(*req.Location.Page, *req.Location.TotalPages) + } + + currentPage := 0 + totalPages := 200 + if req.Location.Page != nil { + currentPage = *req.Location.Page + } + if req.Location.TotalPages != nil { + totalPages = *req.Location.TotalPages + } + if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 { + totalPages = int(mediaItem.PageCount.Int32) + } + + var epubcfi pgtype.Text + if req.Location.Epubcfi != nil { + epubcfi = pgtype.Text{String: *req.Location.Epubcfi, Valid: true} + } + + var chapter pgtype.Int4 + if req.Location.Chapter != nil { + chapter = pgtype.Int4{Int32: int32(*req.Location.Chapter), Valid: true} + } + + var viewportY pgtype.Float8 + if req.Location.ViewportY != nil { + viewportY = pgtype.Float8{Float64: *req.Location.ViewportY, Valid: true} + } + + var lastSyncDevice, lastSyncSource pgtype.Text + if req.DeviceMetadata.DeviceType != "" { + lastSyncDevice = pgtype.Text{String: req.DeviceMetadata.DeviceType, Valid: true} + lastSyncSource = pgtype.Text{String: req.Source, Valid: true} + } + + updated, err := h.db.UpdateUniversalProgress(context.Background(), database.UpdateUniversalProgressParams{ + MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, + UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, + Percentage: pgtype.Float8{Float64: percentage, Valid: true}, + Epubcfi: epubcfi, + Chapter: chapter, + ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true}, + ViewportY: viewportY, + LastSyncDevice: lastSyncDevice, + LastSyncSource: lastSyncSource, + CurrentPage: pgtype.Int4{Int32: int32(currentPage), Valid: true}, + TotalPages: pgtype.Int4{Int32: int32(totalPages), Valid: true}, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to update progress") + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "sync_status": "success", + "progress_updated": true, + "percentage": percentage, + "current_page": currentPage, + "total_pages": totalPages, + "last_read_at": updated.LastReadAt.Time, + }) +} + +// GetProgressHistory retrieves reading session history +func (h *Handler) GetProgressHistory(c echo.Context) error { + user := MustGetAuthenticatedUser(c) + + mediaItemID, err := uuid.Parse(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID") + } + + limit := 50 + if l := c.QueryParam("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { + limit = parsed + } + } + + history, err := h.db.GetReadingHistory(context.Background(), database.GetReadingHistoryParams{ + UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, + MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, + Limit: int32(limit), + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to get history") + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "media_item_id": mediaItemID, + "sessions": history, + "count": len(history), + }) +}