diff --git a/web/src/reader/core/parser-manager.ts b/web/src/reader/core/parser-manager.ts new file mode 100644 index 0000000..6489485 --- /dev/null +++ b/web/src/reader/core/parser-manager.ts @@ -0,0 +1,164 @@ +// Parser Manager - Routes files to appropriate parsers +// Procedural style: Functions, not classes + +import JSZip from "jszip"; +import { parseEPUB } from "../parsers/epub-parserss"; +import { parseFB2 } from "../parsers/fb2-parserr"; +import { parseTXT } from "../parsers/txt-parserr"; +import { parseHTML } from "../parsers/html-parserr"; + +// ============================================================ +// Parser Registry +// ============================================================ + +const PARSER_REGISTRY: ParserEntry[] = [ + { + format: "epub", + mimeType: "application/epub+zip", + extensions: [".epub"], + side: "client", + }, + { + format: "fb2", + mimeType: "application/fb2", + extensions: [".fb2", ".fb2.zip"], + side: "client", + }, + { + format: "txt", + mimeType: "text/plain", + extensions: [".txt"], + side: "client", + }, + { + format: "html", + mimeType: "text/html", + extensions: [".html", ".htm"], + side: "client", + }, + { + format: "mobi", + mimeType: "application/x-mobipocket-ebook", + extensions: [".mobi", ".azw"], + side: "server", + }, + { + format: "azw3", + mimeType: "application/vnd.amazon.mobi8-ebook", + extensions: [".azw3"], + side: "server", + }, + { + format: "docx", + mimeType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + extensions: [".docx"], + side: "server", + }, + { + format: "rtf", + mimeType: "application/rtf", + extensions: [".rtf"], + side: "server", + }, +]; + +interface ParserEntry { + format: string; + mimeType: string; + extensions: string[]; + side: "client" | "server"; +} + +// ============================================================ +// Parser Detection +// ============================================================ + +export function detectParserFormat( + mimeType: string, + extension: string, +): ParserEntry | null { + return ( + PARSER_REGISTRY.find( + (entry) => + entry.mimeType === mimeType || + entry.extensions.includes(extension.toLowerCase()), + ) || null + ); +} + +export function requiresServerParsing( + mimeType: string, + extension: string, +): boolean { + const entry = detectParserFormat(mimeType, extension); + return entry?.side === "server" || false; +} + +// ============================================================ +// Main Parse Function (Router) +// ============================================================ + +export async function parseEbook( + file: Blob, + mimeType: string, + extension: string, +): Promise { + const entry = detectParserFormat(mimeType, extension); + + if (!entry) { + throw new Error(`Unsupported ebook format: ${mimeType}, ${extension}`); + } + + if (entry.side === "server") { + return parseEbookOnServer(file, entry.format); + } else { + return parseEbookOnClient(file, entry.format); + } +} + +// ============================================================ +// Client-Side Parsing +// ============================================================ + +async function parseEbookOnClient( + file: Blob, + format: string, +): Promise { + switch (format) { + case "epub": + return parseEPUB(file); + case "fb2": + return parseFB2(file); + case "txt": + return parseTXT(file); + case "html": + return parseHTML(file); + default: + throw new Error(`Client-side parser not implemented for: ${format}`); + } +} + +// ============================================================ +// Server-Side Parsing (API Call) +// ============================================================ + +async function parseEbookOnServer( + file: Blob, + format: string, +): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append("format", format); + + const response = await fetch("/readers/parse", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`Server parsing failed: ${response.statusText}`); + } + + return await response.json(); +} diff --git a/web/src/reader/core/reader-context.ts b/web/src/reader/core/reader-context.ts new file mode 100644 index 0000000..22d2343 --- /dev/null +++ b/web/src/reader/core/reader-context.ts @@ -0,0 +1,84 @@ +import type { ReaderMetadata } from "../../types/reader"; + +type ReaderType = "ebook" | "pdf" | "comic" | "manga"; + +interface UniversalReader { + type: "ebook"; + cif: any; + currentSpineIndex: number; +} + +interface PDFReader { + type: "pdf"; + doc: any; + currentPage: number; +} + +interface ComicReader { + type: "comic"; + images: Blob[]; + currentPage: number; +} + +interface MangaReader { + type: "manga"; + images: Blob[]; + currentPage: number; + readingDirection: "rtl" | "vertical"; +} + +type CurrentReader = UniversalReader | PDFReader | ComicReader | MangaReader; + +export interface ReaderContext { + getState: () => { + currentReader: CurrentReader | null; + readerMetadata: ReaderMetadata | null; + }; + setState: (updates: Partial) => void; + navigation: { + nextPage: () => void; + previousPage: () => void; + goToPage: (page: number) => void; + goToChapter: (chapterIndex: number) => void; + }; + render: () => void; + elements: { + readerContent: HTMLElement; + chrome: HTMLElement | null; + progressDisplay: HTMLElement | null; + }; + events: { + on: (event: string, handler: Function) => void; + emit: (event: string, data?: any) => void; + }; +} + +export interface ReaderState { + currentReader: CurrentReader | null; + readerMetadata: ReaderMetadata | null; +} + +export function createReaderContext( + getState: () => ReaderState, + setState: (updates: Partial) => void, + navigation: ReaderContext["navigation"], + render: () => void, +): ReaderContext { + return { + getState, + setState, + navigation, + render, + elements: { + readerContent: document.getElementById("reader-content")!, + chrome: document.getElementById("reader-chrome"), + progressDisplay: document.getElementById("progress-display"), + }, + events: { + on: (event: string, handler: Function) => readerEvents.on(event, handler), + emit: (event: string, data?: any) => readerEvents.emit(event, data), + }, + }; +} + +import { readerEvents } from "./reader-events"; diff --git a/web/src/reader/core/reader-events.ts b/web/src/reader/core/reader-events.ts new file mode 100644 index 0000000..a04818a --- /dev/null +++ b/web/src/reader/core/reader-events.ts @@ -0,0 +1,41 @@ +type ReaderEventType = + | "readerReady" + | "pageChanged" + | "chapterChanged" + | "zoomChanged" + | "themeChanged" + | "progressUpdated" + | "beforePageChange" + | "afterPageChange"; + +class EventBus { + private listeners = new Map(); + + on(event: ReaderEventType, handler: Function): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event)!.push(handler); + } + + off(event: ReaderEventType, handler: Function): void { + const handlers = this.listeners.get(event); + if (handlers) { + const index = handlers.indexOf(handler); + if (index > -1) { + handlers.splice(index, 1); + } + } + } + + emit(event: ReaderEventType, data?: any): void { + this.listeners.get(event)?.forEach((handler) => handler(data)); + } + + clear(): void { + this.listeners.clear(); + } +} + +export const readerEvents = new EventBus(); +export type { ReaderEventType }; diff --git a/web/src/reader/core/reader-navigation.ts b/web/src/reader/core/reader-navigation.ts new file mode 100644 index 0000000..d10acac --- /dev/null +++ b/web/src/reader/core/reader-navigation.ts @@ -0,0 +1,266 @@ +import type { ReaderMetadata } from "../../types/reader"; +import type { CurrentReader } from "./reader-context"; +import { getState, setState } from "./reader-state"; +import { readerEvents } from "./reader-events"; +import { updateReadingProgress } from "./reader-services"; + +export function createNavigationAPI() { + return { + nextPage: () => { + const state = getState(); + if (!state.currentReader) return; + + readerEvents.emit("beforePageChange", state.currentReader); + + if (state.currentReader.type === "ebook") { + if ( + state.currentReader.currentSpineIndex < + state.currentReader.cif.spine.length - 1 + ) { + state.currentReader.currentSpineIndex++; + renderSpineItem(); + } + } else if (state.currentReader.type === "pdf") { + const totalPages = state.readerMetadata?.total_pages || 0; + if (state.currentReader.currentPage < totalPages) { + state.currentReader.currentPage++; + renderPDFPage(); + } + } else if ( + state.currentReader.type === "comic" || + state.currentReader.type === "manga" + ) { + if ( + state.currentReader.currentPage < + state.currentReader.images.length - 1 + ) { + state.currentReader.currentPage++; + renderComicPage(); + } + } + + setState({ currentReader: state.currentReader }); + sendProgressUpdate(); + readerEvents.emit("pageChanged", getCurrentPage()); + readerEvents.emit("afterPageChange", state.currentReader); + }, + + previousPage: () => { + const state = getState(); + if (!state.currentReader) return; + + readerEvents.emit("beforePageChange", state.currentReader); + + if (state.currentReader.type === "ebook") { + if (state.currentReader.currentSpineIndex > 0) { + state.currentReader.currentSpineIndex--; + renderSpineItem(); + } + } else if (state.currentReader.type === "pdf") { + if (state.currentReader.currentPage > 1) { + state.currentReader.currentPage--; + renderPDFPage(); + } + } else if ( + state.currentReader.type === "comic" || + state.currentReader.type === "manga" + ) { + if (state.currentReader.currentPage > 0) { + state.currentReader.currentPage--; + renderComicPage(); + } + } + + setState({ currentReader: state.currentReader }); + sendProgressUpdate(); + readerEvents.emit("pageChanged", getCurrentPage()); + readerEvents.emit("afterPageChange", state.currentReader); + }, + + goToPage: (page: number) => { + const state = getState(); + if (!state.currentReader) return; + + readerEvents.emit("beforePageChange", state.currentReader); + + if (state.currentReader.type === "ebook") { + if (page >= 0 && page < state.currentReader.cif.spine.length) { + state.currentReader.currentSpineIndex = page; + renderSpineItem(); + } + } else if (state.currentReader.type === "pdf") { + if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) { + state.currentReader.currentPage = page; + renderPDFPage(); + } + } else if ( + state.currentReader.type === "comic" || + state.currentReader.type === "manga" + ) { + if (page >= 0 && page < state.currentReader.images.length) { + state.currentReader.currentPage = page; + renderComicPage(); + } + } + + setState({ currentReader: state.currentReader }); + sendProgressUpdate(); + readerEvents.emit("pageChanged", page); + readerEvents.emit("afterPageChange", state.currentReader); + }, + + goToChapter: (chapterIndex: number) => { + const state = getState(); + if (!state.readerMetadata?.chapter_metadata?.chapters) return; + + const chapters = state.readerMetadata.chapter_metadata.chapters; + if (chapterIndex < 0 || chapterIndex >= chapters.length) return; + + const chapter = chapters[chapterIndex]; + const pageAPI = createNavigationAPI(); + pageAPI.goToPage(chapter.start_page); + + readerEvents.emit("chapterChanged", chapter); + }, + }; +} + +function renderSpineItem() { + const state = getState(); + if (state.currentReader?.type !== "ebook") return; + + const spineItem = + state.currentReader.cif.spine[state.currentReader.currentSpineIndex]; + const container = document.getElementById("reader-content"); + if (!container) return; + + container.innerHTML = spineItem.content; + applyReaderTheme(); + applyTypography(); +} + +async function renderPDFPage() { + const state = getState(); + if (state.currentReader?.type !== "pdf") return; + + const container = document.getElementById("reader-content"); + if (!container) return; + + const { getPDFPage } = await import("../pdf/pdfjs-wrapper"); + + try { + const page = await getPDFPage(state.currentReader.currentPage); + const viewport = page.getViewport({ scale: 1.5 }); + const canvas = document.createElement("canvas"); + canvas.width = viewport.width; + canvas.height = viewport.height; + canvas.className = "pdf-page mx-auto"; + const ctx = canvas.getContext("2d"); + if (ctx) { + await page.render({ + canvasContext: ctx, + viewport: viewport, + canvas: canvas, + }).promise; + container.innerHTML = ""; + container.appendChild(canvas); + } + } catch (error) { + console.error("Failed to render PDF page:", error); + } +} + +function renderComicPage() { + const state = getState(); + if ( + state.currentReader?.type !== "comic" && + state.currentReader?.type !== "manga" + ) + return; + + const container = document.getElementById("reader-content"); + if ( + !container || + !state.currentReader.images[state.currentReader.currentPage] + ) + return; + + const img = document.createElement("img"); + img.src = URL.createObjectURL( + state.currentReader.images[state.currentReader.currentPage], + ); + img.className = "w-full h-contain object-contain"; + img.alt = `Page ${state.currentReader.currentPage + 1}`; + container.innerHTML = ""; + container.appendChild(img); +} + +function applyReaderTheme() { + const state = getState(); + const settings = getReaderSettings(); + const container = document.getElementById("reader-content"); + if (!container) return; + + container.className = `ebook-content theme-${settings.reading_theme}`; +} + +function applyTypography() { + const state = getState(); + const settings = getReaderSettings(); + const container = document.getElementById("reader-content"); + if (!container) return; + + container.style.fontSize = `${settings.font_size}px`; + container.style.lineHeight = settings.line_height.toString(); + container.style.fontFamily = getFontStack(settings.reading_font); +} + +function getFontStack(font: string): string { + const stacks: Record = { + literata: '"Literata", serif', + crimson: '"Crimson Text", serif', + "source-serif": '"Source Serif 4", serif', + "eb-garamond": '"EB Garamond", serif', + libertinus: '"Libertinus Serif", serif', + "noto-serif": '"Noto Serif", serif', + "charis-sil": '"Charis SIL", serif', + "ibm-plex": '"IBM Plex Serif", serif', + }; + return stacks[font] || stacks["literata"]; +} + +function getReaderSettings() { + // TODO: Load from settings manager + return {} as any; +} + +function sendProgressUpdate() { + const state = getState(); + if (!state.currentReader || !state.readerMetadata) return; + + let currentPage = 0; + let totalPages = 0; + + if (state.currentReader.type === "ebook") { + currentPage = state.currentReader.currentSpineIndex + 1; + totalPages = state.currentReader.cif.spine.length; + } else if (state.currentReader.type === "pdf") { + totalPages = state.readerMetadata.total_pages || 0; + currentPage = state.currentReader.currentPage; + } else if ( + state.currentReader.type === "comic" || + state.currentReader.type === "manga" + ) { + totalPages = state.currentReader.images.length; + currentPage = state.currentReader.currentPage; + } + + updateReadingProgress(state.readerMetadata.media_item_id, { + current_page: currentPage, + total_pages: totalPages, + }); + + readerEvents.emit("progressUpdated", { currentPage, totalPages }); +} + +import { getCurrentPage } from "./reader-state"; diff --git a/web/src/reader/core/reader-services.ts b/web/src/reader/core/reader-services.ts new file mode 100644 index 0000000..30c3a12 --- /dev/null +++ b/web/src/reader/core/reader-services.ts @@ -0,0 +1,55 @@ +import { apiPut } from "../../api"; + +interface ReadingProgress { + current_page: number; + total_pages: number; +} + +export async function updateReadingProgress( + mediaItemId: string, + progress: ReadingProgress, +): Promise { + const response = await apiPut( + `/media-items/${mediaItemId}/progress`, + progress, + ); + await response.json(); +} + +export function getChapterNavigation(chapters: any[]) { + return { + getNextChapter: (currentPage: number) => { + for (let i = 0; i < chapters.length - 1; i++) { + const chapter = chapters[i]; + const nextChapter = chapters[i + 1]; + + if ( + currentPage >= chapter.start_page && + currentPage < nextChapter.start_page + ) { + return nextChapter.start_page; + } + } + return null; + }, + + getPreviousChapter: (currentPage: number) => { + for (let i = 1; i < chapters.length; i++) { + const chapter = chapters[i]; + + if ( + currentPage >= chapter.start_page && + currentPage < chapter.start_page + chapter.page_count + ) { + return chapters[i - 1].start_page; + } + } + + if (currentPage < chapters[0].start_page) { + return null; + } + + return chapters[0].start_page; + }, + }; +} diff --git a/web/src/reader/core/reader-state.ts b/web/src/reader/core/reader-state.ts new file mode 100644 index 0000000..fad3e9c --- /dev/null +++ b/web/src/reader/core/reader-state.ts @@ -0,0 +1,45 @@ +import type { ReaderMetadata, ReaderState } from "./reader-context"; + +type CurrentReader = import("./reader-context").CurrentReader; + +let currentState: ReaderState = { + currentReader: null, + readerMetadata: null, +}; + +export function getState(): ReaderState { + return { ...currentState }; +} + +export function setState(updates: Partial): void { + const oldState = { ...currentState }; + currentState = { ...currentState, ...updates }; + + // Emit events for significant changes + if ( + updates.currentReader && + oldState.currentReader !== updates.currentReader + ) { + readerEvents.emit("readerReady", currentState.currentReader); + } +} + +export function getCurrentReader(): CurrentReader | null { + return currentState.currentReader; +} + +export function getReaderMetadata(): ReaderMetadata | null { + return currentState.readerMetadata; +} + +export function getCurrentPage(): number { + const reader = currentState.currentReader; + if (!reader) return 0; + + if (reader.type === "ebook") { + return reader.currentSpineIndex; + } + return reader.currentPage; +} + +import { readerEvents } from "./reader-events";