diff --git a/internal/handlers/reader.go b/internal/handlers/reader.go index 579faba..aed6f2a 100644 --- a/internal/handlers/reader.go +++ b/internal/handlers/reader.go @@ -53,7 +53,7 @@ type ReaderMetadata struct { } // ShowReader renders the reader page (SSR) -func (h *ReaderHandler) ShowReader(c echo.Context) error { +func (h *ReaderHandler) ShowReader(c *echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { @@ -76,9 +76,20 @@ func (h *ReaderHandler) ShowReader(c echo.Context) error { } userData := user.(database.Users) - // Check library access - hasAccess, err := h.libraryService.UserHasLibraryAccess(c.Request().Context(), userData.ID, mediaItem.LibraryID) - if err != nil || !hasAccess { + // 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"}) } @@ -115,7 +126,7 @@ func (h *ReaderHandler) ShowReader(c echo.Context) error { } // GetPage returns a specific page for lazy loading -func (h *ReaderHandler) GetPage(c echo.Context) error { +func (h *ReaderHandler) GetPage(c *echo.Context) error { mediaItemID := c.Param("mediaItemId") pageNumber := c.Param("pageNumber") @@ -135,11 +146,28 @@ func (h *ReaderHandler) GetPage(c echo.Context) error { return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"}) } - // Check user access - user := c.Get("user").(database.Users) - hasAccess, _ := h.libraryService.UserHasLibraryAccess(c.Request().Context(), user.ID, mediaItem.LibraryID) - if !hasAccess { - return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied"}) + // 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 @@ -169,7 +197,7 @@ func (h *ReaderHandler) GetPage(c echo.Context) error { } // GetChapters returns chapter metadata -func (h *ReaderHandler) GetChapters(c echo.Context) error { +func (h *ReaderHandler) GetChapters(c *echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { @@ -188,7 +216,7 @@ func (h *ReaderHandler) GetChapters(c echo.Context) error { } // GetPanels returns panel detection data for comics -func (h *ReaderHandler) GetPanels(c echo.Context) error { +func (h *ReaderHandler) GetPanels(c *echo.Context) error { mediaItemID := c.Param("mediaItemId") pageNumber := c.Param("pageNumber") @@ -222,7 +250,7 @@ func (h *ReaderHandler) GetPanels(c echo.Context) error { } // UpdatePanels allows manual panel override -func (h *ReaderHandler) UpdatePanels(c echo.Context) error { +func (h *ReaderHandler) UpdatePanels(c *echo.Context) error { mediaItemID := c.Param("mediaItemId") pageNumber := c.Param("pageNumber") @@ -259,7 +287,7 @@ func (h *ReaderHandler) UpdatePanels(c echo.Context) error { } // GetReadingSpeed retrieves reading speed statistics -func (h *ReaderHandler) GetReadingSpeed(c echo.Context) error { +func (h *ReaderHandler) GetReadingSpeed(c *echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { @@ -289,16 +317,16 @@ func (h *ReaderHandler) GetReadingSpeed(c echo.Context) error { } return c.JSON(http.StatusOK, map[string]interface{}{ - "words_per_minute": speed.PagesPerMinute.Float64 * 250, // Estimate WPM - "pages_per_minute": speed.PagesPerMinute.Float64, + "words_per_minute": speed.PagesPerMinute.Float32 * 250, // Estimate WPM + "pages_per_minute": speed.PagesPerMinute.Float32, "pages_read": speed.PagesRead, - "total_reading_minutes": speed.TotalReadingMinutes.Float64, + "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 { +func (h *ReaderHandler) UpdateReadingSpeed(c *echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { @@ -310,7 +338,7 @@ func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error { // Parse request body var req struct { PagesRead int `json:"pages_read"` - TimeSpentMinutes float64 `json:"time_spent_minutes"` + TimeSpentMinutes float32 `json:"time_spent_minutes"` } if err := c.Bind(&req); err != nil { @@ -320,7 +348,7 @@ func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error { // Update reading speed using service err = h.readerService.CalculateReadingSpeed( c.Request().Context(), - user.ID, + uuid.UUID(user.ID.Bytes), parsedUUID, req.PagesRead, req.TimeSpentMinutes, @@ -331,7 +359,7 @@ func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error { } // Calculate and return updated statistics - pagesPerMinute := float64(req.PagesRead) / req.TimeSpentMinutes + pagesPerMinute := float32(req.PagesRead) / req.TimeSpentMinutes wordsPerMinute := pagesPerMinute * 250 // Estimate return c.JSON(http.StatusOK, map[string]interface{}{ @@ -342,7 +370,7 @@ func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error { } // GetPDFOutline returns PDF outline/TOC -func (h *ReaderHandler) GetPDFOutline(c echo.Context) error { +func (h *ReaderHandler) GetPDFOutline(c *echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { @@ -375,7 +403,7 @@ func (h *ReaderHandler) GetPDFOutline(c echo.Context) error { } // GetPDFThumbnail returns a thumbnail for PDF mini-map -func (h *ReaderHandler) GetPDFThumbnail(c echo.Context) error { +func (h *ReaderHandler) GetPDFThumbnail(c *echo.Context) error { mediaItemID := c.Param("mediaItemId") pageNumber := c.Param("pageNumber") @@ -415,7 +443,7 @@ func (h *ReaderHandler) GetPDFThumbnail(c echo.Context) error { } // LookupWord performs dictionary lookup -func (h *ReaderHandler) LookupWord(c echo.Context) error { +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"}) @@ -431,11 +459,11 @@ func (h *ReaderHandler) LookupWord(c echo.Context) error { } // GetSettings retrieves user's reader settings -func (h *ReaderHandler) GetSettings(c echo.Context) error { +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(), user.ID) + 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"}) } @@ -444,7 +472,7 @@ func (h *ReaderHandler) GetSettings(c echo.Context) error { } // UpdateSettings updates user's reader settings -func (h *ReaderHandler) UpdateSettings(c echo.Context) error { +func (h *ReaderHandler) UpdateSettings(c *echo.Context) error { user := c.Get("user").(database.Users) // Parse request body (partial update supported) @@ -464,13 +492,13 @@ func (h *ReaderHandler) UpdateSettings(c echo.Context) error { } // Use reader service to update settings - err := h.readerService.UpdateSettings(c.Request().Context(), user.ID, 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(), user.ID) + updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes)) return c.JSON(http.StatusOK, updatedSettings) } @@ -542,10 +570,53 @@ func (h *ReaderHandler) generatePDFThumbnail(fullPath string, page int, width, h return []byte{}, nil } -// Helper function to convert pgtype.Text to string -func textToString(t pgtype.Text) string { - if !t.Valid { - return "" +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"}) } - return t.String + + // 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", + }) } diff --git a/internal/router/reader.go b/internal/router/reader.go index 6c0ec67..cbf3178 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -1,11 +1,16 @@ package router import ( + "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" + "bookhoard/templates" "bytes" "net/http" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) @@ -16,7 +21,7 @@ func registerReaderRoutes(cfg *Config) { readerService := services.NewReaderService(cfg.Queries, cfg.Worker) cfg.ReaderHandler = handlers.NewReaderHandler( cfg.Queries, - cfg.LibraryService, + services.NewLibraryService(cfg.Queries), readerService, cfg.Worker, ) @@ -28,18 +33,117 @@ func registerReaderRoutes(cfg *Config) { // Reader page (SSR) reader.GET("/:mediaItemId", func(c *echo.Context) error { + // Get user user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } - - // Call the handler to get data - // Then render template - - var buf bytes.Buffer - err = templates.Reader(user, metadata, progress, bookmarks).Render(c.Request().Context(), &buf) + // Parse mediaItemID + mediaItemID := c.Param("mediaItemId") + parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { - return renderErrorPage(c, "Error loading reader", "render_error") + return renderErrorPage(c, "Invalid media item ID", "invalid_id") + } + // Fetch media item (directly, no handler) + mediaItem, err := cfg.Queries.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) + if err != nil { + return renderErrorPage(c, "Media item not found", "not_found") + } + // Check library access (using pattern from opds.go) + userUUID, _ := uuid.Parse(user.ID) + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID)) + if err != nil { + return renderErrorPage(c, "Failed to check access", "access_error") + } + + visible := false + for _, lib := range libraries { + if lib.ID == mediaItem.LibraryID { + visible = true + break + } + } + if !visible { + return renderErrorPage(c, "Access denied", "access_denied") + } + // Get reading progress + var progress database.ReadingProgress + progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{ + MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, + UserID: uuidToPGType(userUUID), + }) + if err != nil && err != pgx.ErrNoRows { + progress = database.ReadingProgress{} + } + // Get bookmarks + bookmarks, _ := cfg.Queries.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{ + MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, + UserID: uuidToPGType(userUUID), + }) + // Convert to template types + mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16]) + metadata := templates.ReaderMetadata{ + MediaItemID: mediaUUID.String(), + Title: mediaItem.Title, + Author: textToString(mediaItem.Author), + CoverImagePath: textToString(mediaItem.CoverImagePath), + LibraryType: mediaItem.FormatGroup, + MimeType: textToString(mediaItem.MimeType), + FilePath: mediaItem.FilePath, + TotalPages: int(mediaItem.PageCount.Int32), + ChapterCount: int(mediaItem.ChapterCount.Int32), + } + // Progress conversion (inline) + progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16]) + progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16]) + progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16]) + templateProgress := templates.ReadingProgress{ + ID: progressUUID.String(), + MediaItemID: progressMediaUUID.String(), + UserID: progressUserUUID.String(), + CurrentPage: int(progress.CurrentPage.Int32), + TotalPages: int(progress.TotalPages.Int32), + Percentage: progress.Percentage.Float64, + EpubCfi: textToString(progress.Epubcfi), + LastReadAt: progress.LastReadAt.Time, + } + // Bookmarks conversion (inline, with loop) + templateBookmarks := make([]templates.Bookmark, len(bookmarks)) + for i, b := range bookmarks { + bookmarkUUID, _ := uuid.FromBytes(b.ID.Bytes[0:16]) + bookmarkMediaUUID, _ := uuid.FromBytes(b.MediaItemID.Bytes[0:16]) + bookmarkUserUUID, _ := uuid.FromBytes(b.UserID.Bytes[0:16]) + + var pageNumber *int + if b.PageNumber.Valid { + pn := int(b.PageNumber.Int32) + pageNumber = &pn + } + + var chapterNumber *int + if b.ChapterNumber.Valid { + cn := int(b.ChapterNumber.Int32) + chapterNumber = &cn + } + + templateBookmarks[i] = templates.Bookmark{ + ID: bookmarkUUID.String(), + MediaItemID: bookmarkMediaUUID.String(), + UserID: bookmarkUserUUID.String(), + PageNumber: pageNumber, + ChapterNumber: chapterNumber, + CfiPosition: textToString(b.CfiPosition), + Title: b.Title, + Position: textToString(b.Position), + Notes: textToString(b.Notes), + CreatedAt: b.CreatedAt.Time, + } + } + // 8. Render template + var buf bytes.Buffer + err = templates.Reader(user, metadata, templateProgress, templateBookmarks).Render(c.Request().Context(), &buf) + if err != nil { + return renderErrorPage(c, "Error rendering reader", "render_error") } return c.HTML(http.StatusOK, buf.String()) }) diff --git a/templates/types.go b/templates/types.go index 28a2a44..758032b 100644 --- a/templates/types.go +++ b/templates/types.go @@ -166,8 +166,7 @@ type ReadingProgress struct { TotalPages int Percentage float64 EpubCfi string - CreatedAt time.Time - UpdatedAt time.Time + LastReadAt time.Time } type Bookmark struct {