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)
This commit is contained in:
@@ -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),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user