diff --git a/READER_IMPLEMENTATION_PLAN.md b/READER_IMPLEMENTATION_PLAN.md index df28530..ec54eed 100644 --- a/READER_IMPLEMENTATION_PLAN.md +++ b/READER_IMPLEMENTATION_PLAN.md @@ -688,21 +688,91 @@ Bookhoard Reader uses a **hybrid theming approach** to balance user personalizat ``` User opens reader ↓ -Backend: GET /api/readers/:mediaItemId +Backend: GET /readers/:mediaItemId (SSR skeleton page) ↓ -Verify access, fetch metadata, progress, bookmarks +Verify access, fetch initial metadata, progress, bookmarks ↓ -SSR render: templates/reader.templ with initial data +SSR render: templates/reader.templ (skeleton shell with data attributes) + - Passes: metadata, progress, bookmarks for initial display + - Renders: chrome UI, panel containers (empty), data attributes + - Does NOT render: actual content pages (loaded by JS) ↓ -Frontend: Initialize appropriate reader (Ebook/PDF/Comic/Manga) +Frontend TypeScript: reader-shell.ts initializes + - Reads: data-media-item-id, data-media-type, data-total-pages + - Detects: media type (ebook/pdf/comic/manga) + - Loads: appropriate reader (EbookReader/PDFReader/ComicReader/MangaReader) ↓ -Load content (lazy load + cache) +Reader fetches content dynamically: + - Ebooks: /readers/:id/pages/:n (HTML chapters) + - Comics: /readers/:id/pages/:n (images) + - PDFs: /readers/:id/pages/:n (text layer + images) ↓ User interacts (turn page, highlight, bookmark) + - All UI logic handled by TypeScript + - Progress updates sent via WebSocket ↓ Real-time sync via WebSocket (reuse existing system) ``` +### 1.5 SSR vs Client-Side Responsibilities + +**IMPORTANT:** The reader uses a hybrid approach - SSR provides the shell, TypeScript handles all interactivity. + +**SSR (Go template) responsibilities:** +- ✅ Render HTML skeleton/structure +- ✅ Embed initial metadata (title, author, cover) +- ✅ Embed initial progress (current page, percentage) +- ✅ Embed initial bookmarks list +- ✅ Render chrome UI (top/bottom bars with basic info) +- ✅ Render panel containers (empty, populated by JS) +- ✅ Set data attributes for Alpine.js initialization +- ❌ Does NOT render actual content pages +- ❌ Does NOT handle reader interactions + +**Client-Side (TypeScript) responsibilities:** +- ✅ Read data attributes from DOM +- ✅ Detect media type and initialize appropriate reader +- ✅ Fetch and render content pages dynamically +- ✅ Handle all user interactions (page turns, zoom, pan) +- ✅ Manage panel state (show/hide, dock, collapse) +- ✅ Sync progress via WebSocket +- ✅ Handle highlights, annotations, bookmarks CRUD +- ✅ Implement reading features (dictionary, search, TOC navigation) + +**Example: What SSR provides:** +```html + +
+

Book Title

+ Page 45/300 +
+
+ +
+
+ +
+ +``` + +**Example: What TypeScript does:** +```typescript +// reader-shell.ts +function initReader() { + const mediaItemId = document.body.dataset.mediaItemId; + const mediaType = document.body.dataset.mediaType; + + // Fetch and render first page + loadPage(mediaItemId, 1, mediaType); + + // Initialize panel interactions + initPanelSystem(); + + // Setup WebSocket sync + initWebSocketSync(mediaItemId); +} +``` + --- ## 2. Database Schema Changes @@ -932,66 +1002,210 @@ Note: This creates import cycle if handler imports templates - follow frontend.g ### 3.1 Reader Routes +**IMPORTANT:** Reader routes are split into two locations following Bookhoard's architecture: +- **SSR page** (HTML): Registered in `internal/router/frontend.go` +- **API endpoints** (JSON): Registered in `internal/router/reader.go` + **File:** `internal/router/reader.go` (new file) ```go package router import ( - "bookhoard/internal/config" "bookhoard/internal/handlers" "bookhoard/internal/services" ) func registerReaderRoutes(cfg *Config) { e := cfg.Echo - + // Create reader service and handler - readerService := services.NewReaderReaderService(cfg.Queries, cfg.Worker) + readerService := services.NewReaderService(cfg.Queries, cfg.Worker) cfg.ReaderHandler = handlers.NewReaderHandler( cfg.Queries, - cfg.LibraryService, + services.NewLibraryService(cfg.Queries), // Create inline, not from cfg readerService, cfg.Worker, ) - + jwtMiddleware := createJWTMiddleware(cfg) - - // Reader page routes (SSR + API) + + // API routes only (JSON responses) + // Note: SSR reader page is registered in frontend.go under /readers/:mediaItemId reader := e.Group("/readers", jwtMiddleware) - - // Reader page (SSR) - reader.GET("/:mediaItemId", cfg.ReaderHandler.ShowReader) - + // Content serving (lazy-loaded pages) reader.GET("/:mediaItemId/pages/:pageNumber", cfg.ReaderHandler.GetPage) - + // Chapter metadata reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters) - + // Panel data (comics/manga) reader.GET("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.GetPanels) reader.PUT("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.UpdatePanels) - + // PDF outline/TOC reader.GET("/:mediaItemId/outline", cfg.ReaderHandler.GetPDFOutline) - + // PDF thumbnails (for mini-map) reader.GET("/:mediaItemId/thumbnails/:pageNumber", cfg.ReaderHandler.GetPDFThumbnail) - + // Reading speed reader.GET("/:mediaItemId/reading-speed", cfg.ReaderHandler.GetReadingSpeed) reader.POST("/:mediaItemId/reading-speed", cfg.ReaderHandler.UpdateReadingSpeed) - + // Dictionary lookup reader.GET("/dictionary/:word", cfg.ReaderHandler.LookupWord) - + // Reader settings reader.GET("/settings", cfg.ReaderHandler.GetSettings) reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings) + + // **PHASE 1 NOTE:** Do NOT register parse route in Phase 1 + // **PHASE 2.5 TASK:** Uncomment below when implementing server-side parsers + // // Parse endpoint for server-side formats (MOBI, AZW3, DOCX, RTF) + // reader.POST("/:mediaItemId/parse", cfg.ReaderHandler.ParseEbook) } ``` +**File:** `internal/router/frontend.go` (modify existing) + +Add the SSR reader page route to the `frontendProtected` group: + +```go +// In registerFrontendRoutes function, after other frontendProtected routes: + +// Reader page (SSR) +frontendProtected.GET("/readers/: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") + } + + // Parse mediaItemID + mediaItemID := c.Param("mediaItemId") + parsedUUID, err := uuid.Parse(mediaItemID) + if err != nil { + return renderErrorPage(c, "Invalid media item ID", "invalid_id") + } + + // Fetch media item + mediaItem, err := cfg.Queries.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) + if err != nil { + if err == pgx.ErrNoRows { + return renderErrorPage(c, "Media item not found", "not_found") + } + return renderErrorPage(c, "Failed to fetch media item", "db_error") + } + + // Check library access (using GetUserVisibleLibraries pattern) + 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 to this library", "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 (inline, following frontend.go pattern) + 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, + } + } + + // 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()) +}) +``` + **File:** `internal/router/router.go` (modify existing) Add ReaderHandler to Config struct (around line 38-67): @@ -1049,15 +1263,19 @@ func RegisterRoutes(cfg *Config) *handlers.Handler { ### 3.2 Handler Implementation +**IMPORTANT:** Handlers return JSON only (API endpoints). SSR rendering is handled in `router/frontend.go` to avoid circular dependencies between handlers and templates. + **File:** `internal/handlers/reader.go` (new file) Follow existing patterns from `media.go` and `auth.go`: - Use `database.Queries` for all DB operations - Return JSON responses with consistent structure - Handle errors properly (404, 403, 500) -- Support content negotiation (JSON for API, HTML for SSR) +- **DO NOT import templates package** (causes circular dependency) -**Complete handler implementation:** +**ShowReader method is removed** - SSR is handled by router/frontend.go, not handlers + +**API Handler Structure:** ```go package handlers @@ -1065,14 +1283,11 @@ package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" - "bookhoard/templates" "context" "fmt" - "io" "net/http" "os" "strconv" - "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -1104,87 +1319,8 @@ func NewReaderHandler( 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) - - // Check library access - hasAccess, err := h.libraryService.UserHasLibraryAccess(c.Request().Context(), userData.ID, mediaItem.LibraryID) - if err != nil || !hasAccess { - return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"}) - } - - // 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, - }) - - // Prepare metadata - metadata := ReaderMetadata{ - MediaItemID: mediaItemID, - 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), - ChapterCount: int(mediaItem.ChapterCount), - } - - // Render template - var buf strings.Builder - err = templates.Reader(userData, metadata, progress, bookmarks).Render(c.Request().Context(), &buf) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to render reader"}) - } - - return c.HTML(http.StatusOK, buf.String()) -} +// NOTE: No ShowReader method here - SSR is handled by router/frontend.go +// All methods below return JSON for API endpoints only // GetPage returns a specific page for lazy loading func (h *ReaderHandler) GetPage(c echo.Context) error { @@ -1594,7 +1730,7 @@ func (h *ReaderHandler) extractEbookPage(fullPath string, page int, format strin 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 + return fmt.Sprintf("/readers/comic-image?page=%d", page), nil } func (h *ReaderHandler) extractPDFPage(fullPath string, page int, format string) (string, error) { @@ -2357,7 +2493,7 @@ async function initializeEbookReader(metadata: ReaderMetadata): Promisetext - docData, err := io.ReadAll(docFile) - if err != nil { - return nil, fmt.Errorf("failed to read document.xml: %w", err) - } - - // Extract text content and paragraphs - paragraphs := extractDOCXParagraphs(docData) - - // Extract metadata from docProps/core.xml or docProps/app.xml - metadata := extractDOCXMetadata(zipReader) - - // Convert paragraphs to HTML - htmlContent := convertDOCXToHTML(paragraphs) - - return map[string]interface{}{ - "metadata": metadata, - "spine": []map[string]interface{}{ - { - "id": "docx-content", - "type": "html", - "content": htmlContent, - }, - }, - "toc": []map[string]interface{}{}, - "resources": map[string]interface{}{}, - }, nil -} - -func (h *ReaderHandler) parseRTF(ctx context.Context, filePath string) (interface{}, error) { - // RTF (Rich Text Format) parsing - // RTF is a text-based format with control codes - // Format: {\rtf1\ansi{\fonttbl...}{\colortbl...}\pard Text \par} - - fileData, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read RTF file: %w", err) - } - - // Validate RTF header - if !bytes.HasPrefix(fileData, []byte("{\\rtf")) { - return nil, fmt.Errorf("invalid RTF file format") - } - - // Parse RTF control codes and extract text - // RTF uses backslash commands: \par = paragraph, \b = bold, \i = italic, etc. - textContent, formatting := parseRTFText(fileData) - - // Convert RTF formatting to HTML - htmlContent := convertRTFToHTML(textContent, formatting) - - // Extract metadata from RTF info group - metadata := extractRTFMetadata(fileData) - - return map[string]interface{}{ - "metadata": metadata, - "spine": []map[string]interface{}{ - { - "id": "rtf-content", - "type": "html", - "content": htmlContent, - }, - }, - "toc": []map[string]interface{}{}, - "resources": map[string]interface{}{}, - }, nil -} - -// Helper functions for MOBI parsing -func extractMOBIMetadata(data []byte, field string) string { - // Extract metadata from MOBI header - // This is a simplified implementation - // Full implementation would parse MOBI EXTH headers - return "" -} - -func extractMOBIText(data []byte) string { - // Extract text content from MOBI file - // MOBI text is typically compressed/huffman encoded - // For now, return placeholder - return "

MOBI content extraction requires full parser implementation

" -} - -// Helper functions for AZW3/EPUB parsing -func parseOPFXML(data []byte) (map[string]interface{}, []map[string]interface{}, []map[string]interface{}) { - // Parse OPF XML to extract metadata, spine, and TOC - // Follows the pattern from epub-parser.ts but in Go - metadata := make(map[string]interface{}) - spine := []map[string]interface{}{} - toc := []map[string]interface{}{} - - // Parse XML and extract elements - // This uses Go's encoding/xml package - decoder := xml.NewDecoder(bytes.NewReader(data)) - - // Implementation details would parse: - // - section for title, author, etc. - // - for resource list - // - for reading order - // - for TOC - - return metadata, spine, toc -} - -// Helper functions for DOCX parsing -func openFileFromZip(zipReader *zip.ReadCloser, path string) (io.ReadCloser, error) { - for _, f := range zipReader.File { - if f.Name == path { - return f.Open() - } - } - return nil, fmt.Errorf("file not found in archive: %s", path) -} - -func extractDOCXParagraphs(docData []byte) []map[string]interface{} { - // Parse DOCX XML and extract paragraphs - // Returns array of paragraph objects with text and formatting - paragraphs := []map[string]interface{}{} - - // Parse XML structure - // elements contain paragraphs - // elements contain runs - // elements contain text - - // Simplified implementation - would use xml.Unmarshal - paragraphs = append(paragraphs, map[string]interface{}{ - "text": "Extracted DOCX content", - "bold": false, - "italic": false, - "underline": false, + // 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", }) - - return paragraphs -} - -func extractDOCXMetadata(zipReader *zip.ReadCloser) map[string]interface{} { - metadata := make(map[string]interface{}) - - // Try to read docProps/core.xml - coreXmlPath := "docProps/core.xml" - if coreXmlFile, err := openFileFromZip(zipReader, coreXmlPath); err == nil { - defer coreXmlFile.Close() - coreData, _ := io.ReadAll(coreXmlFile) - // Parse Dublin Core metadata - // , , , etc. - _ = coreData - } - - metadata["title"] = "Document Title" - metadata["author"] = "Unknown Author" - metadata["format"] = "docx" - metadata["is_reflowable"] = true - - return metadata -} - -func convertDOCXToHTML(paragraphs []map[string]interface{}) string { - var html strings.Builder - - html.WriteString("") - - for _, p := range paragraphs { - text, _ := p["text"].(string) - bold, _ := p["bold"].(bool) - italic, _ := p["italic"].(bool) - - html.WriteString("") - html.WriteString(text) - html.WriteString("

") - } - - html.WriteString("") - return html.String() -} - -// Helper functions for RTF parsing -func parseRTFText(data []byte) (string, map[string]interface{}) { - // Parse RTF control codes and extract plain text - // RTF format: {\rtf1\ansi{\fonttbl...}\pard Text \par} - - // Remove control codes and extract text - text := strings.Builder{} - formatting := make(map[string]interface{}) - - // Skip RTF header - idx := 0 - for idx < len(data) { - if data[idx] == '\\' { - // Parse control word - end := idx + 1 - for end < len(data) && data[end] != ' ' && data[end] != '\\' && data[end] != '}' { - end++ - } - - control := string(data[idx+1 : end]) - - // Handle common control words - switch control { - case "par": - text.WriteString("
") - case "tab": - text.WriteString("    ") - case "b": - formatting["bold"] = true - case "b0": - formatting["bold"] = false - case "i": - formatting["italic"] = true - case "i0": - formatting["italic"] = false - } - - idx = end - } else if data[idx] == '{' || data[idx] == '}' { - // Group delimiters - skip - idx++ - } else if data[idx] >= 32 && data[idx] <= 126 { - // Printable ASCII - text.WriteByte(data[idx]) - idx++ - } else { - // Skip other characters - idx++ - } - } - - return text.String(), formatting -} - -func convertRTFToHTML(text string, formatting map[string]interface{}) string { - var html strings.Builder - - html.WriteString("

") - html.WriteString(text) - html.WriteString("

") - - return html.String() -} - -func extractRTFMetadata(data []byte) map[string]interface{} { - metadata := make(map[string]interface{}) - - // RTF metadata is in {\info {...}} group - // Look for {\title ...}, {\author ...}, etc. - - metadata["title"] = "RTF Document" - metadata["author"] = "Unknown" - metadata["format"] = "rtf" - metadata["is_reflowable"] = true - - return metadata } ``` -**File:** `web/src/reader/reader-shell.ts` +**PHASE 1:** That's it - just the stub above. No parser implementations needed. -```typescript -// Shared reader infrastructure -// Implements chrome control, routing, settings sync +**PHASE 2.5:** See Section 14.5 for complete parser implementations (parseMOBI, parseAZW3, parseDOCX, parseRTF) -import { Alpine } from "../alpine"; -import { getReaderMetadata, updateReadingProgress } from "./api"; -import { SettingsManager } from "./settings-manager"; -import { ProgressIndicator } from "./progress-indicator"; - -let currentReader: EbookReader | PDFReader | ComicReader | MangaReader | null = null; - -function initializeReader(): void { - const mediaItemId = document.body.dataset.mediaItemId; - if (!mediaItemId) return; - - // Fetch metadata - getReaderMetadata(mediaItemId).then((metadata) => { - // Initialize appropriate reader based on type - switch (metadata.library_type) { - case 'ebook': - currentReader = new EbookReader(metadata); - break; - case 'pdf': - currentReader = new PDFReader(metadata); - break; - case 'comic': - currentReader = new ComicReader(metadata); - break; - case 'manga': - currentReader = new MangaReader(metadata); - break; - } - - currentReader?.initialize(); - }); -} - -// Chrome control -function toggleChrome(): void { - const chrome = document.getElementById('reader-chrome'); - chrome?.classList.toggle('hidden'); -} - -function setChromeBehavior(behavior: ReaderSettings['chrome_behavior']): void { - // Auto-hide, always-visible, or hide-on-scroll -} - -// Export for Alpine integration -Alpine.data('readerShell', () => ({ - init() { - initializeReader(); - } -})); -``` +--- ### 4.5 Progress Indicator (KOReader-style) @@ -3553,7 +3263,6 @@ async function savePanelState(panelId: string, panelState: PanelState): Promise< export { initializePanelDockSystem, registerPanel, applyPanelState }; ``` -``` --- @@ -3653,7 +3362,7 @@ async function parseEbookOnServer(file: Blob, format: string): Promise formData.append('file', file); formData.append('format', format); - const response = await fetch('/api/readers/parse', { + const response = await fetch('/readers/parse', { method: 'POST', body: formData, }); @@ -5129,220 +4838,6 @@ function extractSnippet(text: string, offset: number, length: number): string { } ``` -**File:** `web/src/reader/ebook/html-renderer.ts` - -```typescript -// HTML rendering with theme support, font loading, and image handling - -interface RendererConfig { - readingTheme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; // Reading-optimized themes - readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; // Bundled libre fonts - fontSize: number; - lineHeight: number; - marginWidth: number; - textAlign: 'left' | 'justify'; - columnCount: 1 | 2; // Single or double column -} - -// HTML rendering with theme support, font loading, and image handling -// Procedural implementation (no OOP) - -const loadedFonts = new Set(); - -async function renderDocument( - container: HTMLElement, - doc: HTMLDocument, - config: RendererConfig -): Promise { - applyTheme(container, config.readingTheme); - applyTypography(container, config); - injectReaderStyles(container); - await loadEmbeddedFonts(doc); - processImages(doc); - - container.innerHTML = ''; - container.appendChild(doc.body); - - applyColumnLayout(container, config); -} - -function applyTheme( - container: HTMLElement, - readingTheme: RendererConfig['readingTheme'] -): void { - const readingThemes: Record> = { - 'light': { - '--bg-primary': '#ffffff', - '--text-primary': '#1a1a1a', - '--text-secondary': '#666666', - '--accent': '#0066cc' - }, - 'sepia': { - '--bg-primary': '#f4ecd8', - '--text-primary': '#5f4b32', - '--text-secondary': '#8b7355', - '--accent': '#8b4513' - }, - 'dark': { - '--bg-primary': '#1a1b26', - '--text-primary': '#c0caf5', - '--text-secondary': '#565f89', - '--accent': '#7aa2f7' - }, - 'night': { - '--bg-primary': '#0d1117', - '--text-primary': '#c9d1d9', - '--text-secondary': '#8b949e', - '--accent': '#58a6ff' - }, - 'high-contrast': { - '--bg-primary': '#000000', - '--text-primary': '#ffffff', - '--text-secondary': '#cccccc', - '--accent': '#ffff00' - } - }; - - const theme = readingThemes[readingTheme] || readingThemes['dark']; - - for (const [key, value] of Object.entries(theme)) { - container.style.setProperty(key, value); - } -} - -function applyTypography(container: HTMLElement, config: RendererConfig): void { - const style = document.createElement('style'); - const fontStack = getFontStack(config.readingFont); - - style.textContent = ` - .ebook-content { - font-family: ${fontStack}; - font-size: ${config.fontSize}px; - line-height: ${config.lineHeight}; - text-align: ${config.textAlign}; - padding: 0 ${config.marginWidth}px; - max-width: 100%; - overflow-wrap: break-word; - } - - .ebook-content p { - margin-bottom: 1em; - text-indent: ${config.textAlign === 'justify' ? '1.5em' : '0'}; - } - - .ebook-content img { - max-width: 100%; - height: auto; - display: block; - margin: 1em auto; - } - - .ebook-content a { - color: var(--accent); - text-decoration: underline; - } - - .ebook-content a:active { - color: var(--text-secondary); - } - `; - - container.appendChild(style); -} - -function injectReaderStyles(container: HTMLElement): void { - container.setAttribute('role', 'main'); - container.setAttribute('aria-label', 'Book content'); -} - -async function loadEmbeddedFonts(doc: HTMLDocument): Promise { - const styleSheets = doc.querySelectorAll('style'); - - for (const sheet of styleSheets) { - const fontFaceRegex = /@font-face\s*{([^}]+)}/g; - const matches = sheet.textContent?.matchAll(fontFaceRegex) || []; - - for (const match of matches) { - const fontFace = match[1]; - const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace); - - if (urlMatch) { - const fontUrl = urlMatch[1]; - await loadFont(fontUrl); - } - } - } -} - -async function loadFont(fontUrl: string): Promise { - if (loadedFonts.has(fontUrl)) return; - - try { - const fontFace = new FontFace('custom-font', `url(${fontUrl})`); - await fontFace.load(); - document.fonts.add(fontFace); - loadedFonts.add(fontUrl); - } catch (error) { - console.error('Failed to load font:', fontUrl, error); - } -} - -function processImages(doc: HTMLDocument): void { - const images = doc.querySelectorAll('img'); - - images.forEach((img) => { - img.setAttribute('loading', 'lazy'); - - if (!img.alt) { - img.alt = 'Image from book'; - } - - img.style.cursor = 'pointer'; - img.addEventListener('click', () => { - showImageFullscreen(img.src); - }); - }); -} - -function showImageFullscreen(src: string): void { - const modal = document.createElement('div'); - modal.className = 'fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50'; - modal.onclick = () => modal.remove(); - - const img = document.createElement('img'); - img.src = src; - img.className = 'max-w-full max-h-full object-contain'; - - modal.appendChild(img); - document.body.appendChild(modal); -} - -function applyColumnLayout(container: HTMLElement, config: RendererConfig): void { - if (config.columnCount === 2) { - container.style.columnCount = '2'; - container.style.columnGap = `${config.marginWidth}px`; - container.style.columnRule = '1px solid var(--text-secondary)'; - } else { - container.style.columnCount = 'auto'; - } -} - -function updateRendererConfig( - container: HTMLElement, - currentConfig: RendererConfig, - newConfig: Partial -): RendererConfig { - const updatedConfig = { ...currentConfig, ...newConfig }; - - const currentDoc = container.querySelector('.ebook-content')?.ownerDocument; - if (currentDoc) { - renderDocument(container, currentDoc as HTMLDocument, updatedConfig); - } - - return updatedConfig; -} -``` - ### 5.10 Libre Reading Fonts (Bundled) **8 Open Source Fonts Optimized for Extended Reading** @@ -10090,7 +9585,7 @@ async function loadComicPage( ): Promise { const token = localStorage.getItem('token'); const response = await fetch( - `/api/readers/${state.mediaItemId}/pages/${pageNumber}`, + `/readers/${state.mediaItemId}/pages/${pageNumber}`, { headers: { Authorization: `Bearer ${token}` } } @@ -10163,7 +9658,7 @@ self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Cache reader pages - if (url.pathname.startsWith('/api/readers/') && url.pathname.includes('/pages/')) { + if (url.pathname.startsWith('/readers/') && url.pathname.includes('/pages/')) { event.respondWith( caches.open(CACHE_NAME).then((cache) => { return cache.match(event.request).then((response) => { @@ -10182,7 +9677,7 @@ self.addEventListener('fetch', (event) => { } // Cache dictionary lookups - if (url.pathname.startsWith('/api/readers/dictionary/')) { + if (url.pathname.startsWith('/readers/dictionary/')) { event.respondWith( caches.open(OFFLINE_CACHE).then((cache) => { return cache.match(event.request).then((response) => { @@ -10817,7 +10312,7 @@ func TestReaderEndpoints(t *testing.T) { t.Run("Get Page - Lazy Loading", func(t *testing.T) { // Test page lazy loading endpoint - req := httptest.NewRequest("GET", fmt.Sprintf("/api/readers/%s/pages/1", mediaItem.ID), nil) + req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/pages/1", mediaItem.ID), nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() @@ -10835,7 +10330,7 @@ func TestReaderEndpoints(t *testing.T) { t.Run("Get Chapters", func(t *testing.T) { // Test chapter metadata endpoint - req := httptest.NewRequest("GET", fmt.Sprintf("/api/readers/%s/chapters", mediaItem.ID), nil) + req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/chapters", mediaItem.ID), nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() @@ -10854,7 +10349,7 @@ func TestReaderEndpoints(t *testing.T) { // Create a comic media item comicItem := createTestComicMediaItem(t, ctx, queries, library.ID, user.ID) - req := httptest.NewRequest("GET", fmt.Sprintf("/api/readers/%s/panels/1?method=grid", comicItem.ID), nil) + req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/panels/1?method=grid", comicItem.ID), nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() @@ -10892,7 +10387,7 @@ func TestReaderEndpoints(t *testing.T) { bodyBytes, err := json.Marshal(requestBody) require.NoError(t, err) - req := httptest.NewRequest("PUT", fmt.Sprintf("/api/readers/%s/panels/1", comicItem.ID), bytes.NewReader(bodyBytes)) + req := httptest.NewRequest("PUT", fmt.Sprintf("/readers/%s/panels/1", comicItem.ID), bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") @@ -10909,7 +10404,7 @@ func TestReaderEndpoints(t *testing.T) { t.Run("Reading Speed - Get", func(t *testing.T) { // Test reading speed retrieval - req := httptest.NewRequest("GET", fmt.Sprintf("/api/readers/%s/reading-speed", mediaItem.ID), nil) + req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/reading-speed", mediaItem.ID), nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() @@ -10934,7 +10429,7 @@ func TestReaderEndpoints(t *testing.T) { bodyBytes, err := json.Marshal(requestBody) require.NoError(t, err) - req := httptest.NewRequest("POST", fmt.Sprintf("/api/readers/%s/reading-speed", mediaItem.ID), bytes.NewReader(bodyBytes)) + req := httptest.NewRequest("POST", fmt.Sprintf("/readers/%s/reading-speed", mediaItem.ID), bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") @@ -10951,7 +10446,7 @@ func TestReaderEndpoints(t *testing.T) { t.Run("Dictionary Lookup", func(t *testing.T) { // Test dictionary endpoint - req := httptest.NewRequest("GET", "/api/readers/dictionary/example", nil) + req := httptest.NewRequest("GET", "/readers/dictionary/example", nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() @@ -10971,7 +10466,7 @@ func TestReaderEndpoints(t *testing.T) { t.Run("Settings Management - Get", func(t *testing.T) { // Test settings retrieval - req := httptest.NewRequest("GET", "/api/readers/settings", nil) + req := httptest.NewRequest("GET", "/readers/settings", nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() @@ -10998,7 +10493,7 @@ func TestReaderEndpoints(t *testing.T) { bodyBytes, err := json.Marshal(requestBody) require.NoError(t, err) - req := httptest.NewRequest("PUT", "/api/readers/settings", bytes.NewReader(bodyBytes)) + req := httptest.NewRequest("PUT", "/readers/settings", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") @@ -11072,15 +10567,36 @@ func createTestComicMediaItem(t *testing.T, ctx context.Context, queries *databa - [ ] Create database schema (panel_data, reading_speed, dictionary_cache, reader_settings) - [ ] Create reader service layer (`internal/services/reader_service.go`) - [ ] Create reader handlers (`internal/handlers/reader.go`) + - [ ] Implement all API endpoints (GetPage, GetChapters, GetPanels, etc.) + - [ ] **Stub ParseEbook to return 501 for server-side formats** (MOBI/AZW3/DOCX/RTF not implemented in Phase 1) - [ ] Register reader routes (`internal/router/reader.go`) + - [ ] API routes: `/readers/:id/pages/:n`, `/readers/:id/chapters`, etc. + - [ ] SSR route: `/readers/:id` (in `router/frontend.go`) + - [ ] **Do NOT register parse route** - server-side parsing is Phase 2 - [ ] Create reader template (`templates/reader.templ`) - [ ] Implement reader shell infrastructure - [ ] Implement settings manager (DB + localStorage) - [ ] Implement progress indicator (KOReader-style) -- [ ] Create basic ebook reader (HTML rendering) +- [ ] Create basic ebook reader (HTML rendering) - EPUB/FB2/TXT/HTML only - [ ] Create basic comic reader (image display) - [ ] Integration tests for infrastructure +**Phase 1 Code Cleanup:** +- [ ] **Remove or comment out stub parser functions** (lines ~579-1001 in handlers/reader.go): + - [ ] Remove `ParseEbook` method (returns 501, no route registered) + - [ ] Remove `parseMOBI`, `parseAZW3`, `parseDOCX`, `parseRTF` stubs + - [ ] Remove helper functions: `extractMOBIMetadata`, `extractMOBIText`, `parseOPFXML`, `openFileFromZip`, `extractDOCXParagraphs`, `extractDOCXMetadata`, `convertDOCXToHTML`, `parseRTFText`, `convertRTFToHTML`, `extractRTFMetadata` + - [ ] Add TODO comment: "// Phase 2.5: Implement server-side MOBI/AZW3/DOCX/RTF parsers" +- [ ] Result: handlers/reader.go reduced from ~1000 lines to ~577 lines (working code only) +- [ ] File compiles without errors +- [ ] All Phase 1 API endpoints functional + +**Phase 1 Scope Limitations:** +- ✅ Client-side parsing: EPUB, FB2, TXT, HTML (TypeScript parsers) +- ❌ Server-side parsing: MOBI, AZW3, DOCX, RTF (returns "not implemented" error) +- ✅ PDF reader (using pdf.js) +- ✅ Comic/Manga readers (image display) + ### Phase 2: Comic/Manga Features (Week 3-4) - [ ] Implement grid-based panel detection - [ ] Implement panel zoom with animations @@ -11090,7 +10606,43 @@ func createTestComicMediaItem(t *testing.T, ctx context.Context, queries *databa - [ ] Implement chapter detection for all media types - [ ] Integration tests for comic/manga features -### Phase 3: Advanced Features (Week 5-6) +### Phase 2.5: Server-Side Parsers (Week 5) +**Complete hybrid parsing strategy - add support for complex formats** + +- [ ] Implement `ParseEbook` endpoint: `POST /readers/:id/parse` + - [ ] Register route in `router/reader.go` + - [ ] Route to parser based on MIME type +- [ ] Implement MOBI parser (`parseMOBI` handler) + - [ ] Parse MOBI header and metadata + - [ ] Extract text content (handle Huffman compression) + - [ ] Convert to CIF (HTML structure) + - [ ] Unit tests for MOBI parsing +- [ ] Implement AZW3/KF8 parser (`parseAZW3` handler) + - [ ] Parse AZW3 container (ZIP-based) + - [ ] Extract EPUB3 content from KF8 container + - [ ] Convert to CIF + - [ ] Unit tests for AZW3 parsing +- [ ] Implement DOCX parser (`parseDOCX` handler) + - [ ] Parse DOCX ZIP structure + - [ ] Extract document.xml for paragraphs + - [ ] Convert formatting to HTML + - [ ] Convert to CIF + - [ ] Unit tests for DOCX parsing +- [ ] Implement RTF parser (`parseRTF` handler) + - [ ] Parse RTF markup + - [ ] Extract text and formatting + - [ ] Convert to HTML/CIF + - [ ] Unit tests for RTF parsing +- [ ] Integration tests for all server-side parsers +- [ ] Update TypeScript `parser-manager.ts` to call parse endpoint +- [ ] Update `requiresServerParsing()` to detect MOBI/AZW3/DOCX/RTF + +**Note:** These formats are complex and require specialized parsing libraries. Consider using: +- Go's `archive/zip` for ZIP-based formats (AZW3, DOCX) +- Third-party libraries for MOBI decompression +- Custom RTF parser (spec is public) + +### Phase 3: Advanced Features (Week 6-7) - [ ] Implement ML-based panel detection (optional enhancement) - [ ] Implement manual panel editor - [ ] Implement dictionary popup for ebooks @@ -11226,30 +10778,30 @@ meta: seq: 2 http: method: GET - url: '{{base_url}}/api/readers/{{media_item_id}}/pages/{{page_number}}' + url: '{{base_url}}/readers/{{media_item_id}}/pages/{{page_number}}' auth: inherit body: type: none docs: |- ## Get Book Page - + Retrieves a specific page of the book for rendering. - + **Authentication:** Required (Bearer token) - + **Path Parameters:** - `media_item_id` (string): Media Item UUID - `page_number` (number): Page number to retrieve - + **Query Parameters:** - `format` (string, optional): Response format ('html', 'text', 'json') - + **Response:** - `content` (string): Page content (HTML or text) - `page_number` (number): Current page number - `total_pages` (number): Total pages in book - `chapter_title` (string, optional): Current chapter title - + **Error Responses:** - 401: Invalid authentication - 404: Page not found @@ -11264,7 +10816,7 @@ meta: seq: 3 http: method: GET - url: '{{base_url}}/api/readers/{{media_item_id}}/chapters' + url: '{{base_url}}/readers/{{media_item_id}}/chapters' auth: inherit body: type: none @@ -11307,7 +10859,7 @@ meta: seq: 4 http: method: GET - url: '{{base_url}}/api/readers/{{media_item_id}}/panels/{{page_number}}' + url: '{{base_url}}/readers/{{media_item_id}}/panels/{{page_number}}' auth: inherit body: type: none @@ -11357,7 +10909,7 @@ meta: seq: 5 http: method: PUT - url: '{{base_url}}/api/readers/{{media_item_id}}/panels/{{page_number}}' + url: '{{base_url}}/readers/{{media_item_id}}/panels/{{page_number}}' auth: inherit body: type: json @@ -11415,7 +10967,7 @@ meta: seq: 6 http: method: GET - url: '{{base_url}}/api/readers/{{media_item_id}}/reading-speed' + url: '{{base_url}}/readers/{{media_item_id}}/reading-speed' auth: inherit body: type: none @@ -11454,7 +11006,7 @@ meta: seq: 7 http: method: POST - url: '{{base_url}}/api/readers/{{media_item_id}}/reading-speed' + url: '{{base_url}}/readers/{{media_item_id}}/reading-speed' auth: inherit body: type: json @@ -11503,7 +11055,7 @@ meta: seq: 8 http: method: GET - url: '{{base_url}}/api/readers/dictionary/{{word}}' + url: '{{base_url}}/readers/dictionary/{{word}}' auth: inherit body: type: none @@ -11545,7 +11097,7 @@ meta: seq: 9 http: method: GET - url: '{{base_url}}/api/readers/settings' + url: '{{base_url}}/readers/settings' auth: inherit body: type: none @@ -11583,7 +11135,7 @@ meta: seq: 10 http: method: PUT - url: '{{base_url}}/api/readers/settings' + url: '{{base_url}}/readers/settings' auth: inherit body: type: json