Files
bookhoard/internal/handlers/progress.go
T
john-okeefe 6ddc551d64 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)
2026-01-30 16:11:11 -05:00

215 lines
6.7 KiB
Go

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),
})
}