Phase 4 of the reader redesign (foliate-js ea268df): - Webtoon mode for comics: continuous vertical scroll of all pages (900px centered column on wide screens), lazy-loaded with a 150% IntersectionObserver margin, far pages unloaded to bound memory with stable aspect-ratio placeholders so the scrollbar never jumps. Chosen per book (Paged | Webtoon segmented control in Settings → Layout & Display; stored in localStorage per media item since a webtoon title and a paged manga volume want different flows). Toggling reloads the reader — the renderer is chosen at open time — and progress restores from the saved page. Relocate events flow through the same pipeline, so the slider, progress saving, back stack, tap zones, and edge zones all work unchanged. Zoom/fit/ magnifier/spread controls hide in webtoon (natural-width scroll). - Display filters for fixed-layout: brightness (30-130%) and contrast (70-130%) sliders with live preview, plus Night Mode (invert) — also a quick row in the ⋯ tools menu. One --fx-filter CSS var drives everything: ::part(filter) on foliate-view iframes (forwarded via the new exportparts attribute) and the webtoon page images alike. Persisted as fx_brightness/fx_contrast/fx_invert (types + defaults both sides); Restore Defaults resets them.
423 lines
12 KiB
Go
423 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
type ReaderService struct {
|
|
db *database.Queries
|
|
worker *Worker
|
|
}
|
|
|
|
func NewReaderService(db *database.Queries, worker *Worker) *ReaderService {
|
|
return &ReaderService{
|
|
db: db,
|
|
worker: worker,
|
|
}
|
|
}
|
|
|
|
// Chapter represents a detected chapter
|
|
type Chapter struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
StartPage int `json:"start_page"`
|
|
PageCount int `json:"page_count"`
|
|
Level int `json:"level"`
|
|
ParentID *string `json:"parent_id,omitempty"`
|
|
}
|
|
|
|
// Panel represents a detected comic panel
|
|
type Panel struct {
|
|
ID string `json:"id"`
|
|
X int `json:"x"`
|
|
Y int `json:"y"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
ReadingOrder int `json:"reading_order"`
|
|
}
|
|
|
|
// DictionaryEntry represents a word definition
|
|
type DictionaryEntry struct {
|
|
Word string `json:"word"`
|
|
Definition string `json:"definition"`
|
|
PartOfSpeech string `json:"part_of_speech,omitempty"`
|
|
Example string `json:"example,omitempty"`
|
|
Etymology string `json:"etymology,omitempty"`
|
|
}
|
|
|
|
// ChapterDetectionResult contains chapter metadata
|
|
type ChapterDetectionResult struct {
|
|
Chapters []Chapter `json:"chapters"`
|
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
|
DetectedAt time.Time `json:"detected_at"`
|
|
}
|
|
|
|
// DetectChapters analyzes a media item to detect chapter structure
|
|
func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error) {
|
|
// Get media item to determine type
|
|
item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get media item: %w", err)
|
|
}
|
|
|
|
// Check if chapter metadata already exists
|
|
if len(item.ChapterMetadata) > 0 {
|
|
var existing ChapterDetectionResult
|
|
if err := json.Unmarshal(item.ChapterMetadata, &existing); err == nil {
|
|
return existing.Chapters, nil
|
|
}
|
|
}
|
|
|
|
// Detect chapters based on format
|
|
var chapters []Chapter
|
|
|
|
switch item.FormatGroup {
|
|
case "reflowable":
|
|
// For ebooks, parse from TOC if available
|
|
chapters, err = s.detectEbookChapters(ctx, &item)
|
|
case "fixed-layout":
|
|
// For comics/manga, detect page breaks as chapters
|
|
chapters, err = s.detectComicChapters(ctx, &item)
|
|
case "pdf":
|
|
// For PDFs, use PDF outline
|
|
chapters, err = s.detectPDFChapters(ctx, &item)
|
|
default:
|
|
chapters = []Chapter{}
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("chapter detection failed: %w", err)
|
|
}
|
|
|
|
// Cache the results
|
|
result := ChapterDetectionResult{
|
|
Chapters: chapters,
|
|
Metadata: nil,
|
|
DetectedAt: time.Now(),
|
|
}
|
|
|
|
metadataBytes, err := json.Marshal(result)
|
|
if err == nil {
|
|
_, _ = s.db.UpdateMediaItemChapterMetadata(ctx, database.UpdateMediaItemChapterMetadataParams{
|
|
ID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
ChapterMetadata: metadataBytes,
|
|
})
|
|
}
|
|
|
|
return chapters, nil
|
|
}
|
|
|
|
func (s *ReaderService) detectEbookChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
|
|
// For EPUB files, parse the TOC from the OPF file
|
|
// This requires EPUB parsing (see epub-parser.ts)
|
|
// For now, return empty structure
|
|
return []Chapter{}, nil
|
|
}
|
|
|
|
func (s *ReaderService) detectComicChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
|
|
// For comics, treat each page as a potential chapter
|
|
// or group pages by story arcs if metadata exists
|
|
pageCount := int(item.PageCount.Int32)
|
|
|
|
if pageCount <= 0 {
|
|
return []Chapter{}, nil
|
|
}
|
|
|
|
chapters := make([]Chapter, 0)
|
|
chapterSize := 20 // Group pages into chapters of 20 pages each
|
|
|
|
for i := 0; i < pageCount; i += chapterSize {
|
|
endPage := i + chapterSize
|
|
if endPage > pageCount {
|
|
endPage = pageCount
|
|
}
|
|
|
|
chapters = append(chapters, Chapter{
|
|
ID: fmt.Sprintf("chapter-%d", len(chapters)+1),
|
|
Title: fmt.Sprintf("Page %d-%d", i+1, endPage),
|
|
StartPage: i + 1,
|
|
PageCount: endPage - i,
|
|
Level: 1,
|
|
})
|
|
}
|
|
|
|
return chapters, nil
|
|
}
|
|
|
|
func (s *ReaderService) detectPDFChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
|
|
// For PDFs, use pdfcpu to extract outline/bookmarks
|
|
// This requires PDF parsing library
|
|
return []Chapter{}, nil
|
|
}
|
|
|
|
// DetectPanels analyzes a comic page to detect panel boundaries
|
|
func (s *ReaderService) DetectPanels(
|
|
ctx context.Context,
|
|
mediaItemID uuid.UUID,
|
|
pageNumber int,
|
|
method string,
|
|
) ([]Panel, error) {
|
|
// Check if panels already exist in cache
|
|
cached, err := s.db.GetPanelData(ctx, database.GetPanelDataParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
PageNumber: int32(pageNumber),
|
|
})
|
|
|
|
if err == nil {
|
|
var panels []Panel
|
|
if err := json.Unmarshal(cached.Panels, &panels); err == nil {
|
|
return panels, nil
|
|
}
|
|
}
|
|
|
|
// Detect panels using specified method
|
|
var panels []Panel
|
|
|
|
switch method {
|
|
case "grid":
|
|
panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
|
|
case "ml":
|
|
panels, err = s.detectPanelsML(ctx, mediaItemID, pageNumber)
|
|
case "manual":
|
|
panels, err = s.detectPanelsManual(ctx, mediaItemID, pageNumber)
|
|
default:
|
|
// Default to grid detection
|
|
panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("panel detection failed: %w", err)
|
|
}
|
|
|
|
// Cache the results
|
|
panelsJSON, _ := json.Marshal(panels)
|
|
// Insert into panel_data table
|
|
_ = panelsJSON
|
|
|
|
return panels, nil
|
|
}
|
|
|
|
func (s *ReaderService) detectPanelsGrid(
|
|
ctx context.Context,
|
|
mediaItemID uuid.UUID,
|
|
pageNumber int,
|
|
) ([]Panel, error) {
|
|
// Simple grid-based panel detection
|
|
// Divide page into 2x2 or 3x3 grid
|
|
// This is a simplified implementation
|
|
|
|
panels := []Panel{
|
|
{
|
|
ID: "panel-1",
|
|
X: 0,
|
|
Y: 0,
|
|
Width: 50,
|
|
Height: 100,
|
|
ReadingOrder: 1,
|
|
},
|
|
{
|
|
ID: "panel-2",
|
|
X: 50,
|
|
Y: 0,
|
|
Width: 50,
|
|
Height: 100,
|
|
ReadingOrder: 2,
|
|
},
|
|
}
|
|
|
|
return panels, nil
|
|
}
|
|
|
|
func (s *ReaderService) detectPanelsML(
|
|
ctx context.Context,
|
|
mediaItemID uuid.UUID,
|
|
pageNumber int,
|
|
) ([]Panel, error) {
|
|
// ML-based panel detection
|
|
// This would require a trained model
|
|
// For now, fall back to grid detection
|
|
return s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
|
|
}
|
|
|
|
func (s *ReaderService) detectPanelsManual(
|
|
ctx context.Context,
|
|
mediaItemID uuid.UUID,
|
|
pageNumber int,
|
|
) ([]Panel, error) {
|
|
// Manual panel detection returns existing manually-set panels
|
|
// These would be stored in the panel_data table
|
|
return []Panel{}, nil
|
|
}
|
|
|
|
// CalculateReadingSpeed updates reading speed statistics
|
|
func (s *ReaderService) CalculateReadingSpeed(
|
|
ctx context.Context,
|
|
userID uuid.UUID,
|
|
mediaItemID uuid.UUID,
|
|
pagesRead int,
|
|
minutes float32,
|
|
) error {
|
|
if minutes <= 0 {
|
|
return fmt.Errorf("invalid time: must be positive")
|
|
}
|
|
|
|
pagesPerMinute := float32(pagesRead) / minutes
|
|
|
|
// Get or create reading speed record
|
|
_, err := s.db.GetReadingSpeed(ctx, database.GetReadingSpeedParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
// Create new record
|
|
_, err = s.db.CreateReadingSpeed(ctx, database.CreateReadingSpeedParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
PagesPerMinute: pgtype.Float4{Float32: pagesPerMinute, Valid: true},
|
|
PagesRead: pgtype.Int4{Int32: int32(pagesRead), Valid: true},
|
|
TotalReadingMinutes: pgtype.Float4{Float32: minutes, Valid: true},
|
|
LastReadAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
|
})
|
|
} else {
|
|
// Update existing record with moving average
|
|
// This would require an UpdateReadingSpeed query
|
|
_ = pagesPerMinute
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// LookupWord retrieves dictionary entry for a word
|
|
func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error) {
|
|
// Check cache first
|
|
cached, err := s.db.GetDictionaryEntry(ctx, word)
|
|
|
|
if err == nil {
|
|
return &DictionaryEntry{
|
|
Word: cached.Word,
|
|
Definition: cached.Definition,
|
|
PartOfSpeech: cached.PartOfSpeech.String,
|
|
Example: cached.Example.String,
|
|
Etymology: cached.Etymology.String,
|
|
}, nil
|
|
}
|
|
|
|
// Not in cache, fetch from dictionary API
|
|
entry, err := s.fetchDictionaryEntry(ctx, word)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Cache the entry
|
|
_, _ = s.db.CreateDictionaryEntry(ctx, database.CreateDictionaryEntryParams{
|
|
Word: entry.Word,
|
|
Definition: entry.Definition,
|
|
PartOfSpeech: pgtype.Text{String: entry.PartOfSpeech, Valid: entry.PartOfSpeech != ""},
|
|
Example: pgtype.Text{String: entry.Example, Valid: entry.Example != ""},
|
|
Etymology: pgtype.Text{String: entry.Etymology, Valid: entry.Etymology != ""},
|
|
})
|
|
|
|
return entry, nil
|
|
}
|
|
|
|
func (s *ReaderService) fetchDictionaryEntry(ctx context.Context, word string) (*DictionaryEntry, error) {
|
|
// Fetch from external dictionary API
|
|
// For now, return a placeholder
|
|
return &DictionaryEntry{
|
|
Word: word,
|
|
Definition: fmt.Sprintf("Definition for %s", word),
|
|
}, nil
|
|
}
|
|
|
|
// GetSettings retrieves reader settings for a user
|
|
func (s *ReaderService) GetSettings(
|
|
ctx context.Context,
|
|
userID uuid.UUID,
|
|
) (map[string]interface{}, error) {
|
|
// Get settings from database
|
|
settings, err := s.db.GetReaderSettings(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
|
|
|
if err != nil {
|
|
// Return default settings
|
|
return s.getDefaultSettings(), nil
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(settings, &result); err != nil {
|
|
return s.getDefaultSettings(), nil
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// UpdateSettings updates reader settings for a user
|
|
func (s *ReaderService) UpdateSettings(
|
|
ctx context.Context,
|
|
userID uuid.UUID,
|
|
settings map[string]interface{},
|
|
) error {
|
|
// Merge with existing settings
|
|
existing, err := s.GetSettings(ctx, userID)
|
|
if err != nil {
|
|
existing = s.getDefaultSettings()
|
|
}
|
|
|
|
// Merge settings (partial update)
|
|
for key, value := range settings {
|
|
existing[key] = value
|
|
}
|
|
|
|
// Serialize and save
|
|
settingsJSON, err := json.Marshal(existing)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to serialize settings: %w", err)
|
|
}
|
|
|
|
// Update in database
|
|
_, err = s.db.UpsertReaderSettings(ctx, database.UpsertReaderSettingsParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
SettingValue: settingsJSON,
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
func (s *ReaderService) getDefaultSettings() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"chrome_behavior": "auto-hide",
|
|
"progress_mode": "pages",
|
|
"chrome_theme": "tokyo-night",
|
|
"reading_theme": "dark",
|
|
"reading_font": "literata",
|
|
"font_size": 16,
|
|
"line_height": 1.6,
|
|
"margin_width": 20,
|
|
"tap_zone_size": 30,
|
|
"auto_scroll": false,
|
|
"panel_zoom_enabled": true,
|
|
"double_page_spread": true,
|
|
"pdf_interaction_mode": "select",
|
|
"fx_brightness": 1,
|
|
"fx_contrast": 1,
|
|
"fx_invert": false,
|
|
"tap_zones_enabled": true,
|
|
|
|
// Dockable panel defaults
|
|
"panel_layout": map[string]interface{}{
|
|
"toc": map[string]interface{}{"side": "left", "visible": true, "collapsed": false, "width_px": 320, "order": 1, "locked": false, "last_valid_side": "left"},
|
|
"settings": map[string]interface{}{"side": "left", "visible": false, "collapsed": true, "width_px": 380, "order": 2, "locked": false, "last_valid_side": "left"},
|
|
"navigator": map[string]interface{}{"side": "right", "visible": true, "collapsed": false, "width_px": 200, "order": 1, "locked": false, "last_valid_side": "right"},
|
|
"bookmarks": map[string]interface{}{"side": "right", "visible": false, "collapsed": true, "width_px": 280, "order": 2, "locked": false, "last_valid_side": "right"},
|
|
"mobile_nav_visible": false,
|
|
},
|
|
}
|
|
}
|