Add progress list page showing reading progress across all devices: - GetAllProgress API endpoint: Returns all user progress with media details - GetAllProgressData helper: Fetches progress data for SSR rendering - ProgressWithMedia struct: Combines progress with book metadata - Progress page template: Displays progress bars, device icons, sync sources Features: - Visual progress bars with percentage - Device-specific icons (Kobo, KOReader, Web, Mobile) - Last sync timestamp and device attribution - EPUB CFI location display - Cover image support with fallback - Responsive grid layout This gives users a unified view of their reading progress across all synced devices.
365 lines
10 KiB
Go
365 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookmann/internal/database"
|
|
wsync "bookmann/internal/sync"
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"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 = wsync.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")
|
|
}
|
|
|
|
// Broadcast progress update to all connected WebSocket clients
|
|
deviceName := "Web"
|
|
deviceType := "web"
|
|
if req.DeviceMetadata.DeviceType != "" {
|
|
deviceType = req.DeviceMetadata.DeviceType
|
|
deviceName = req.DeviceMetadata.DeviceType
|
|
}
|
|
|
|
h.connManager.BroadcastProgressUpdate(mediaItemID, percentage, wsync.SourceDevice{
|
|
ID: uuid.UUID(user.ID.Bytes).String(),
|
|
Name: deviceName,
|
|
Type: deviceType,
|
|
})
|
|
|
|
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),
|
|
})
|
|
}
|
|
|
|
type ProgressWithMedia struct {
|
|
MediaItemID uuid.UUID
|
|
Title string
|
|
Author string
|
|
CoverImagePath string
|
|
Percentage float64
|
|
CurrentPage int32
|
|
TotalPages int32
|
|
LastReadAt time.Time
|
|
Epubcfi string
|
|
LastSyncDevice string
|
|
}
|
|
|
|
// GetAllProgress retrieves all progress for a user with sync source info
|
|
func (h *Handler) GetAllProgress(c echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
progressList := []ProgressWithMedia{}
|
|
|
|
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
|
Limit: 1000, Offset: 0,
|
|
})
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media items")
|
|
}
|
|
|
|
for _, mediaItem := range mediaItems {
|
|
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
|
MediaItemID: mediaItem.ID,
|
|
UserID: user.ID,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
coverPath := ""
|
|
if mediaItem.CoverImagePath.Valid {
|
|
coverPath = mediaItem.CoverImagePath.String
|
|
}
|
|
|
|
author := ""
|
|
if mediaItem.Author.Valid {
|
|
author = mediaItem.Author.String
|
|
}
|
|
|
|
epubcfi := ""
|
|
if progress.Epubcfi.Valid {
|
|
epubcfi = progress.Epubcfi.String
|
|
}
|
|
|
|
deviceName := ""
|
|
if progress.LastSyncDevice.Valid {
|
|
deviceName = progress.LastSyncDevice.String
|
|
}
|
|
|
|
progressList = append(progressList, ProgressWithMedia{
|
|
MediaItemID: progress.MediaItemID.Bytes,
|
|
Title: mediaItem.Title,
|
|
Author: author,
|
|
CoverImagePath: coverPath,
|
|
Percentage: progress.Percentage.Float64,
|
|
CurrentPage: progress.CurrentPage.Int32,
|
|
TotalPages: progress.TotalPages.Int32,
|
|
LastReadAt: progress.LastReadAt.Time,
|
|
Epubcfi: epubcfi,
|
|
LastSyncDevice: deviceName,
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"progress": progressList,
|
|
"total": len(progressList),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) GetAllProgressData(c echo.Context) ([]ProgressWithMedia, error) {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
progressList := []ProgressWithMedia{}
|
|
|
|
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
|
Limit: 1000,
|
|
Offset: 0,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, mediaItem := range mediaItems {
|
|
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
|
MediaItemID: mediaItem.ID,
|
|
UserID: user.ID,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
coverPath := ""
|
|
if mediaItem.CoverImagePath.Valid {
|
|
coverPath = mediaItem.CoverImagePath.String
|
|
}
|
|
|
|
author := ""
|
|
if mediaItem.Author.Valid {
|
|
author = mediaItem.Author.String
|
|
}
|
|
|
|
epubcfi := ""
|
|
if progress.Epubcfi.Valid {
|
|
epubcfi = progress.Epubcfi.String
|
|
}
|
|
|
|
deviceName := ""
|
|
if progress.LastSyncDevice.Valid {
|
|
deviceName = progress.LastSyncDevice.String
|
|
}
|
|
|
|
progressList = append(progressList, ProgressWithMedia{
|
|
MediaItemID: progress.MediaItemID.Bytes,
|
|
Title: mediaItem.Title,
|
|
Author: author,
|
|
CoverImagePath: coverPath,
|
|
Percentage: progress.Percentage.Float64,
|
|
CurrentPage: progress.CurrentPage.Int32,
|
|
TotalPages: progress.TotalPages.Int32,
|
|
LastReadAt: progress.LastReadAt.Time,
|
|
Epubcfi: epubcfi,
|
|
LastSyncDevice: deviceName,
|
|
})
|
|
}
|
|
|
|
return progressList, nil
|
|
}
|
|
|