Files
bookhoard/internal/handlers/reader.go
T
john-okeefe 3825b93d22 feat(reader): expand reading theme palette with 20 named themes
Replace the original 5-theme allowlist (light, sepia, dark, night,
high-contrast) with a richer 20-theme palette organized into tonal
families: neutrals (light, paper, slate, oled), warm tones (sepia,
parchment, warm, candlelight), cool tones (azure, sky, arctic, frost),
and evening tones (dusk, sunset, twilight, forest, moss, solarized).

The backend validator in UpdateSettings now accepts all 20 theme names,
and the frontend Tailwind build is updated to include the new theme CSS
variables and preflight reset.
2026-04-19 21:50:40 -04:00

656 lines
21 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"context"
"fmt"
"net/http"
"os"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type ReaderHandler struct {
db *database.Queries
libraryService *services.LibraryService
readerService *services.ReaderService
worker *services.Worker
}
func NewReaderHandler(
db *database.Queries,
libraryService *services.LibraryService,
readerService *services.ReaderService,
worker ...*services.Worker,
) *ReaderHandler {
rh := &ReaderHandler{
db: db,
libraryService: libraryService,
readerService: readerService,
}
if len(worker) > 0 && worker[0] != nil {
rh.worker = worker[0]
}
return rh
}
// ReaderMetadata contains information needed to render the reader
type ReaderMetadata struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
LibraryType string `json:"library_type"`
MimeType string `json:"mime_type"`
FilePath string `json:"file_path"`
TotalPages int `json:"total_pages"`
ChapterCount int `json:"chapter_count"`
}
// ShowReader renders the reader page (SSR)
func (h *ReaderHandler) ShowReader(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
// Get media item
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch media item"})
}
// Get user from context (set by JWT middleware)
user := c.Get("user")
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "User not authenticated"})
}
userData := user.(database.Users)
// Get user's visible libraries
libraries, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), userData.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check library access"})
}
// Check if media item's library is in visible list
visible := false
for _, lib := range libraries {
if lib.ID == mediaItem.LibraryID {
visible = true
break
}
}
if !visible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"})
}
// Get library with type information for panel detection decision
libraryWithType, err := h.db.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get library info"})
}
// Determine if panel detection should be enabled
enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup)
// Get reading progress
var progress database.ReadingProgress
progress, err = h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
UserID: userData.ID,
})
if err != nil && err != pgx.ErrNoRows {
progress = database.ReadingProgress{}
}
// Get bookmarks
bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
UserID: userData.ID,
})
// Return JSON response instead of rendering template
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": mediaItemID,
"title": mediaItem.Title,
"author": textToString(mediaItem.Author),
"cover_image_path": textToString(mediaItem.CoverImagePath),
"library_type": mediaItem.FormatGroup,
"mime_type": textToString(mediaItem.MimeType),
"file_path": mediaItem.FilePath,
"total_pages": mediaItem.PageCount,
"chapter_count": mediaItem.ChapterCount,
"progress": progress,
"bookmarks": bookmarks,
"enable_panel_detection": enablePanelDetection,
"format_group": mediaItem.FormatGroup,
"manga_type": mediaItem.MangaType,
"reading_direction": mediaItem.ReadingDirection,
})
}
// GetPage returns a specific page for lazy loading
func (h *ReaderHandler) GetPage(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
pageNumber := c.Param("pageNumber")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
page, err := strconv.Atoi(pageNumber)
if err != nil || page < 1 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
}
// Get media item
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
}
// Get user from context (set by JWT middleware)
user := c.Get("user")
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "User not authenticated"})
}
userData := user.(database.Users)
// Get user's visible libraries
libraries, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), userData.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check library access"})
}
// Check if media item's library is in visible list
visible := false
for _, lib := range libraries {
if lib.ID == mediaItem.LibraryID {
visible = true
break
}
}
if !visible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"})
}
// Resolve full file path
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"})
}
// Get requested format
format := c.QueryParam("format")
if format == "" {
format = "html"
}
// Extract page content based on format
content, err := h.extractPageContent(c.Request().Context(), &mediaItem, page, format, fullPath)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"content": content,
"page_number": page,
"total_pages": mediaItem.PageCount,
"media_item_id": mediaItemID,
})
}
// GetChapters returns chapter metadata
func (h *ReaderHandler) GetChapters(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
// Use reader service to detect chapters
chapters, err := h.readerService.DetectChapters(c.Request().Context(), parsedUUID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect chapters"})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"chapters": chapters,
})
}
// GetPanels returns panel detection data for comics
func (h *ReaderHandler) GetPanels(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
pageNumber := c.Param("pageNumber")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
page, err := strconv.Atoi(pageNumber)
if err != nil || page < 1 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
}
// Get detection method from query
method := c.QueryParam("method")
if method == "" {
method = "grid"
}
// Use reader service to detect panels
panels, err := h.readerService.DetectPanels(c.Request().Context(), parsedUUID, page, method)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect panels"})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"page_number": page,
"detection_method": method,
"panels": panels,
})
}
// UpdatePanels allows manual panel override
func (h *ReaderHandler) UpdatePanels(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
pageNumber := c.Param("pageNumber")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
page, err := strconv.Atoi(pageNumber)
if err != nil || page < 1 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
}
// Parse request body
var req struct {
Panels []services.Panel `json:"panels"`
DetectionMethod string `json:"detection_method"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
}
// Store manual panel override in database
// This would require implementing UpsertPanelData in database/queries.sql
_ = parsedUUID
_ = page
_ = req
return c.JSON(http.StatusOK, map[string]interface{}{
"success": true,
"message": "Panels updated successfully",
})
}
// GetReadingSpeed retrieves reading speed statistics
func (h *ReaderHandler) GetReadingSpeed(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
user := c.Get("user").(database.Users)
// Get reading speed from database
speed, err := h.db.GetReadingSpeed(c.Request().Context(), database.GetReadingSpeedParams{
UserID: user.ID,
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
})
if err != nil {
if err == pgx.ErrNoRows {
// Return zero values if no reading has occurred
return c.JSON(http.StatusOK, map[string]interface{}{
"words_per_minute": 0,
"pages_per_minute": 0,
"pages_read": 0,
"total_reading_minutes": 0,
"last_read_at": nil,
})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch reading speed"})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"words_per_minute": speed.PagesPerMinute.Float32 * 250, // Estimate WPM
"pages_per_minute": speed.PagesPerMinute.Float32,
"pages_read": speed.PagesRead,
"total_reading_minutes": speed.TotalReadingMinutes.Float32,
"last_read_at": speed.LastReadAt.Time,
})
}
// UpdateReadingSpeed updates reading speed statistics
func (h *ReaderHandler) UpdateReadingSpeed(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
user := c.Get("user").(database.Users)
// Parse request body
var req struct {
PagesRead int `json:"pages_read"`
TimeSpentMinutes float32 `json:"time_spent_minutes"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
}
// Update reading speed using service
err = h.readerService.CalculateReadingSpeed(
c.Request().Context(),
uuid.UUID(user.ID.Bytes),
parsedUUID,
req.PagesRead,
req.TimeSpentMinutes,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update reading speed"})
}
// Calculate and return updated statistics
pagesPerMinute := float32(req.PagesRead) / req.TimeSpentMinutes
wordsPerMinute := pagesPerMinute * 250 // Estimate
return c.JSON(http.StatusOK, map[string]interface{}{
"success": true,
"words_per_minute": wordsPerMinute,
"pages_per_minute": pagesPerMinute,
})
}
// GetPDFOutline returns PDF outline/TOC
func (h *ReaderHandler) GetPDFOutline(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
// Get media item
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
}
// Only PDFs have outlines
if mediaItem.FormatGroup != "pdf" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Not a PDF file"})
}
// Extract PDF outline using pdfcpu
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"})
}
// Use pdfcpu to extract outline
outline := h.extractPDFOutline(fullPath)
return c.JSON(http.StatusOK, map[string]interface{}{
"outline": outline,
})
}
// GetPDFThumbnail returns a thumbnail for PDF mini-map
func (h *ReaderHandler) GetPDFThumbnail(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
pageNumber := c.Param("pageNumber")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
page, err := strconv.Atoi(pageNumber)
if err != nil || page < 1 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
}
// Get media item
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
}
// Get thumbnail size from query
width := c.QueryParam("width")
height := c.QueryParam("height")
// Generate thumbnail using pdfcpu
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"})
}
thumbnail, err := h.generatePDFThumbnail(fullPath, page, width, height)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate thumbnail"})
}
c.Response().Header().Set("Content-Type", "image/png")
return c.Blob(http.StatusOK, "image/png", thumbnail)
}
// LookupWord performs dictionary lookup
func (h *ReaderHandler) LookupWord(c *echo.Context) error {
word := c.Param("word")
if word == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Word parameter required"})
}
// Use reader service for dictionary lookup
entry, err := h.readerService.LookupWord(c.Request().Context(), word)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Word not found in dictionary"})
}
return c.JSON(http.StatusOK, entry)
}
// GetSettings retrieves user's reader settings
func (h *ReaderHandler) GetSettings(c *echo.Context) error {
user := c.Get("user").(database.Users)
// Use reader service to get settings
settings, err := h.readerService.GetSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes))
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch settings"})
}
return c.JSON(http.StatusOK, settings)
}
// UpdateSettings updates user's reader settings
func (h *ReaderHandler) UpdateSettings(c *echo.Context) error {
user := c.Get("user").(database.Users)
// Parse request body (partial update supported)
var settings map[string]interface{}
if err := c.Bind(&settings); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
}
// Validate settings
if readingTheme, ok := settings["reading_theme"].(string); ok {
validThemes := map[string]bool{
"light": true, "paper": true, "sepia": true, "parchment": true,
"warm": true, "candlelight": true, "azure": true, "sky": true,
"arctic": true, "frost": true, "dusk": true, "sunset": true,
"twilight": true, "forest": true, "moss": true, "slate": true,
"oled": true, "solarized": true,
}
if !validThemes[readingTheme] {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid reading theme"})
}
}
// Use reader service to update settings
err := h.readerService.UpdateSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes), settings)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"})
}
// Return updated settings
updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes))
return c.JSON(http.StatusOK, updatedSettings)
}
// Helper functions
func (h *ReaderHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
// Get library folders
folders, err := h.db.GetLibraryFolders(ctx, libraryID)
if err != nil {
return "", err
}
// Try each folder until we find the file
for _, folder := range folders {
fullPath := folder.FolderPath + string(os.PathSeparator) + relativePath
if _, err := os.Stat(fullPath); err == nil {
return fullPath, nil
}
}
return "", fmt.Errorf("file not found in any library folder")
}
func (h *ReaderHandler) extractPageContent(ctx context.Context, item *database.MediaItems, page int, format, fullPath string) (string, error) {
// Extract content based on format
// This is a simplified implementation
// In production, would use format-specific parsers
switch item.FormatGroup {
case "reflowable":
// For EPUB and other ebooks, extract the requested page/chapter
return h.extractEbookPage(fullPath, page, format)
case "fixed-layout":
// For comics, return image data URL or path
return h.extractComicPage(fullPath, page, format)
case "pdf":
// For PDFs, extract text or image
return h.extractPDFPage(fullPath, page, format)
default:
return "", fmt.Errorf("unsupported format: %s", item.FormatGroup)
}
}
func (h *ReaderHandler) extractEbookPage(fullPath string, page int, format string) (string, error) {
// Simplified EPUB extraction
// In production, would use epub-parser.ts logic
return fmt.Sprintf("<div class='ebook-page'><p>Page %d content</p></div>", page), nil
}
func (h *ReaderHandler) extractComicPage(fullPath string, page int, format string) (string, error) {
// For comics, return image path or data URL
return fmt.Sprintf("/api/readers/comic-image?page=%d", page), nil
}
func (h *ReaderHandler) extractPDFPage(fullPath string, page int, format string) (string, error) {
// For PDFs, extract text content or image
return fmt.Sprintf("<div class='pdf-page'><p>Page %d content</p></div>", page), nil
}
func (h *ReaderHandler) extractPDFOutline(fullPath string) []map[string]interface{} {
// Extract PDF outline using pdfcpu
// This is a placeholder
return []map[string]interface{}{}
}
func (h *ReaderHandler) generatePDFThumbnail(fullPath string, page int, width, height string) ([]byte, error) {
// Generate thumbnail using pdfcpu
// This is a placeholder
return []byte{}, nil
}
func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(400, map[string]string{"error": "Invalid media item ID"})
}
// Fetch media item
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(404, map[string]string{"error": "Media item not found"})
}
return c.JSON(500, map[string]string{"error": "Failed to fetch media item"})
}
// Check if format requires server-side parsing
requiresServerParsing := false
formatName := ""
switch mediaItem.MimeType.String {
case "application/x-mobipocket-ebook":
requiresServerParsing = true
formatName = "MOBI"
case "application/vnd.amazon.mobi8-ebook":
requiresServerParsing = true
formatName = "AZW3/KF8"
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
requiresServerParsing = true
formatName = "DOCX"
case "application/rtf":
requiresServerParsing = true
formatName = "RTF"
}
if requiresServerParsing {
// Phase 1: Return 501 for server-side formats
return c.JSON(501, map[string]interface{}{
"error": fmt.Sprintf("Server-side %s parsing is not yet implemented", formatName),
"message": fmt.Sprintf("%s format support is planned for Phase 2.5", formatName),
"suggestion": "Please convert your ebook to EPUB format for now",
"format": formatName,
"phase": "2.5",
})
}
// Client-side format - should not call this endpoint
return c.JSON(400, map[string]string{
"error": "This format should be parsed client-side, not on the server",
})
}
// shouldEnablePanelDetection determines if panel detection should be enabled
// based on library type and format group
func shouldEnablePanelDetection(libraryType string, formatGroup string) bool {
panelLibraries := map[string]bool{
"manga": true,
"comics": true,
}
panelFormats := map[string]bool{
"fixed_layout": true,
"comic_archive": true,
}
return panelLibraries[libraryType] && panelFormats[formatGroup]
}