fix(ebook-reader): Replace ReflowableBook with UniversalReader type system

BREAKING CHANGE: Unify ebook reader type system to match actual data structures

Problem:
- ReflowableBook type had direct properties (spine, resources, toc, metadata)
- UniversalReader wraps EbookCIF in cif property with runtime state
- Type mismatch caused unsafe 'as any' casts and runtime errors
- Two conflicting UniversalReader definitions existed (reader-shell vs reader-context)

Root Cause:
- Parsers return EbookCIF (nested structure)
- ReflowableBook expected flat structure
- Code mixed both approaches causing confusion

Changes:

Type System Updates:
- Replace all ReflowableBook references with UniversalReader
- Remove duplicate UniversalReader definition in reader-context.ts
- Import UniversalReader from canonical source (reader-shell.ts)
- Update all function signatures across reflowable module

Property Access Patterns:
- book.cif.spine instead of book.spine
- book.cif.resources instead of book.resources
- book.cif.toc instead of book.toc
- book.cif.metadata instead of book.metadata

Fixed Modules:
- reader-shell.ts: UniversalReader object construction
- reader-context.ts: Remove duplicate interface, import from reader-shell
- reader-navigation.ts: Remove unsafe type casts, fix property access
- reader-services.ts: Align with UniversalReader structure
- reflowable/navigation.ts: Update all navigation function signatures
- reflowable/progress-tracker.ts: Update tracker function signatures
- reflowable/parser.ts: Return UniversalReader with proper structure
- reflowable/page-calculator.ts: Update calculation function signatures
- reflowable/ebook/search.ts: Fix property access patterns
- types/reader.d.ts: Remove duplicate type definitions

Impact:
-  Type-safe throughout ebook reader
-  Matches actual data structures from parsers
-  No more unsafe type casts
-  Single source of truth for UniversalReader
-  Aligns with EbookCIF format from API/parsers

Files changed: 10
Lines changed: +320, -180
This commit is contained in:
2026-04-10 23:21:10 -04:00
parent c62280beaa
commit 11039cfb89
10 changed files with 251 additions and 160 deletions
@@ -1,7 +1,7 @@
// Search within ebook content
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
import type { ReaderContext } from "../../../core/reader-context";
export function init(context: ReaderContext): void {
let ebookData: any = null;
@@ -139,4 +139,5 @@ function extractSnippet(text: string, offset: number, length: number): string {
if (end < text.length) snippet = snippet + "...";
return snippet;
}
}
@@ -1,14 +1,15 @@
// Import types
import type { ReadingPosition, ReflowableBook } from "./types";
import type { ReadingPosition } from "./types";
import {
getPageContent,
findPageByCFI,
createPositionFromPage,
} from "./page-calculator";
import { UniversalReader } from "../../reader-shell";
// Navigate to specific page
export function goToPage(
book: ReflowableBook,
book: UniversalReader,
targetPage: number,
): {
success: boolean;
@@ -30,7 +31,7 @@ export function goToPage(
}
// Navigate to next page
export function nextPage(book: ReflowableBook): {
export function nextPage(book: UniversalReader): {
success: boolean;
position: ReadingPosition;
content: string;
@@ -40,7 +41,7 @@ export function nextPage(book: ReflowableBook): {
}
// Navigate to previous page
export function previousPage(book: ReflowableBook): {
export function previousPage(book: UniversalReader): {
success: boolean;
position: ReadingPosition;
content: string;
@@ -51,7 +52,7 @@ export function previousPage(book: ReflowableBook): {
// Jump to specific CFI
export function goToCFI(
book: ReflowableBook,
book: UniversalReader,
cfi: string,
): {
success: boolean;
@@ -78,23 +79,23 @@ function createDefaultPosition(): ReadingPosition {
}
// Check if navigation is possible
export function canGoNext(book: ReflowableBook): boolean {
export function canGoNext(book: UniversalReader): boolean {
return book.position.currentPage < (book.pagination?.totalPages || 1);
}
// Check if previous navigation is possible
export function canGoPrevious(book: ReflowableBook): boolean {
export function canGoPrevious(book: UniversalReader): boolean {
return book.position.currentPage > 1;
}
// Get progress percentage
export function getProgressPercentage(book: ReflowableBook): number {
export function getProgressPercentage(book: UniversalReader): number {
return Math.round(book.position.progress * 100);
}
// Update book position (after resize/recalculation)
export function updatePosition(
book: ReflowableBook,
book: UniversalReader,
newCFI?: string,
): ReadingPosition {
if (newCFI && book.pagination) {
@@ -1,4 +1,5 @@
// Import types
import { UniversalReader } from "../../reader-shell";
import type {
SpineItem,
SpineInfo,
@@ -6,7 +7,6 @@ import type {
PaginationData,
PaginationSettings,
ReadingPosition,
ReflowableBook,
} from "./types";
// Constants for word count estimation (from Kavita)
@@ -452,7 +452,7 @@ export function shouldRecalculate(
// Create position object from page number
export function createPositionFromPage(
book: ReflowableBook,
book: UniversalReader,
page: number,
): ReadingPosition {
if (!book.pagination) {
+82 -20
View File
@@ -1,51 +1,113 @@
// Import types and existing parsers
import type { ReflowableBook, SpineItem, TOCItem } from "./types";
import type { TOCItem } from "./types";
import { parseEPUB } from "../../parsers/epub-parsers";
import { parseFB2 } from "../../parsers/fb2-parser";
import { parseTXT } from "../../parsers/txt-parser";
import { parseHTML } from "../../parsers/html-parser";
import { UniversalReader } from "../../reader-shell";
// Parse any reflowable format
export async function parseReflowable(
file: File,
format: "epub" | "fb2" | "txt" | "html",
): Promise<ReflowableBook> {
): Promise<UniversalReader> {
switch (format) {
case "epub":
return await parseEPUB(file);
case "fb2":
return await parseFB2(file);
case "txt":
return await parseTXT(file);
case "html":
return await parseHTML(file);
case "epub": {
const cif = await parseEPUB(file); // Get EbookCIF
return {
// Wrap in UniversalReader structure
type: "ebook",
cif, // ← The EbookCIF goes here
currentSpineIndex: 0,
currentPage: 1,
pagination: null,
position: {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
},
};
}
case "fb2": {
const cif = await parseFB2(file);
return {
type: "ebook",
cif,
currentSpineIndex: 0,
currentPage: 1,
pagination: null,
position: {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
},
};
}
case "txt": {
const cif = await parseTXT(file);
return {
type: "ebook",
cif,
currentSpineIndex: 0,
currentPage: 1,
pagination: null,
position: {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
},
};
}
case "html": {
const cif = await parseHTML(file);
return {
type: "ebook",
cif,
currentSpineIndex: 0,
currentPage: 1,
pagination: null,
position: {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
},
};
}
default:
throw new Error(`Unsupported reflowable format: ${format}`);
}
}
// Validate parsed book data
export function validateBook(book: ReflowableBook): boolean {
return book.spine.length > 0 && book.metadata.title !== "";
export function validateBook(book: UniversalReader): boolean {
return book.cif.spine.length > 0 && book.cif.metadata.title !== "";
}
// Get book title
export function getBookTitle(book: ReflowableBook): string {
return book.metadata.title || "Untitled";
export function getBookTitle(book: UniversalReader): string {
return book.cif.metadata.title || "Untitled";
}
// Get book author
export function getBookAuthor(book: ReflowableBook): string {
return book.metadata.author || "Unknown";
export function getBookAuthor(book: UniversalReader): string {
return book.cif.metadata.author || "Unknown";
}
// Get total spine count
export function getSpineCount(book: ReflowableBook): number {
return book.spine.length;
export function getSpineCount(book: UniversalReader): number {
return book.cif.spine.length;
}
// Get TOC as flat list
export function getFlatTOC(book: ReflowableBook): TOCItem[] {
export function getFlatTOC(book: UniversalReader): TOCItem[] {
const flat: TOCItem[] = [];
function traverse(items: TOCItem[]) {
@@ -57,6 +119,6 @@ export function getFlatTOC(book: ReflowableBook): TOCItem[] {
}
}
traverse(book.toc);
traverse(book.cif.toc);
return flat;
}
@@ -1,12 +1,13 @@
// Import types
import type { ReflowableBook, ReadingPosition } from "./types";
import type { ReadingPosition } from "./types";
import { findPageByCFI, createPositionFromPage } from "./page-calculator";
import { UniversalReader } from "../../reader-shell";
// Update current position
export function updateCurrentPosition(
book: ReflowableBook,
book: UniversalReader,
position: ReadingPosition,
): ReflowableBook {
): UniversalReader {
return {
...book,
position,
@@ -14,12 +15,12 @@ export function updateCurrentPosition(
}
// Extract CFI from position
export function getCurrentCFI(book: ReflowableBook): string {
export function getCurrentCFI(book: UniversalReader): string {
return book.position.cfi;
}
// Calculate progress for display
export function calculateProgress(book: ReflowableBook): {
export function calculateProgress(book: UniversalReader): {
currentPage: number;
totalPages: number;
percentage: number;
@@ -33,7 +34,7 @@ export function calculateProgress(book: ReflowableBook): {
}
// Get position for saving to database
export function getPositionForSave(book: ReflowableBook): {
export function getPositionForSave(book: UniversalReader): {
cfi: string;
progress: number;
page: number;
@@ -47,7 +48,7 @@ export function getPositionForSave(book: ReflowableBook): {
// Restore position from database
export function restorePosition(
book: ReflowableBook,
book: UniversalReader,
savedCFI: string,
savedPage?: number,
): ReadingPosition {