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