diff --git a/README.md b/README.md index cb88c26..dafbdd3 100644 --- a/README.md +++ b/README.md @@ -7,28 +7,63 @@ [![Discussions](https://img.shields.io/badge/Discussions-Ask%20a%20Question-blue)](../../discussions) -# OpenReader WebUI 📄🔊 +# 📄🔊 OpenReader WebUI -OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering a TTS read along experience with narration for EPUB, PDF, TXT, MD, and DOCX documents. It supports multiple TTS providers including OpenAI, Deepinfra, and custom OpenAI-compatible endpoints like [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI) +OpenReader WebUI is an open source text to speech document reader web app built using Next.js, offering a TTS read along experience with narration for EPUB, PDF, TXT, MD, and DOCX documents. It supports multiple TTS providers including OpenAI, Deepinfra, and custom OpenAI-compatible endpoints like [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI) -- 🎯 **Multi-Provider TTS Support**: - - **OpenAI**: tts-1, tts-1-hd, gpt-4o-mini-tts models with voices (alloy, echo, fable, onyx, nova, shimmer) +- 🧠 **(New) Smart Sentence-Aware Narration**: EPUB and PDF playback use shared NLP (compromise) and smart sentence continuation to merge sentences that span pages/chapters for smoother TTS trying to prevent hard cuts at page breaks +- 🎧 **(New) Reliable Audiobook Export**: Create and export audiobooks from PDF and EPUB files **(in m4b or mp3 format using ffmpeg)** with resumable, chapter/page-based export and per-chapter regeneration +- 🎯 **(New) Multi-Provider TTS Support**: - **Deepinfra**: Kokoro-82M, Orpheus-3B, Sesame-1B models with extensive voice libraries - - **Custom OpenAI-Compatible**: Any OpenAI-compatible endpoint with custom voice sets -- 💾 **Local-First Architecture**: Uses IndexedDB browser storage for documents -- 🛜 **Optional Server-side documents**: Manually upload documents to the next backend for all users to download -- 📖 **Read Along Experience**: Follow along with highlighted text as the TTS narrates -- 📄 **Document formats**: EPUB, PDF, TXT, MD, DOCX (with libreoffice installed) -- 🎧 **Audiobook Creation**: Create and export audiobooks from PDF and ePub files **(in m4b format with ffmpeg and aac TTS output)** + - **OpenAI API ($$)**: tts-1, tts-1-hd, gpt-4o-mini-tts models + - **Kokoro-FastAPI**: Self-hosted OpenAI-compatible TTS API server supporting Kokoro-82M and multi-voice combinations (like `af_heart+bf_emma`) + - **Orpheus-FastAPI**: Self-hosted OpenAI-compatible TTS API server supporting Orpheus-3B + - And other Custom OpenAI-compatible endpoints with a `/v1/audio/voices` endpoint +- 🚀 **(New) Optimized TTS Pipeline**: Next.js TTS backend with in-memory LRU audio cache, ETag-aware responses, and in-flight request de-duplication for faster repeat playback +- 💾 **Local-First Architecture**: IndexedDB browser storage for documents and settings (now using Dexie.js) +- 🛜 **Optional Server-side documents**: Manually upload documents to the Next.js backend (and Docker `docstore`) for all users to download +- 📖 **Read Along Experience**: Follow along with highlighted text as the TTS narrates PDF files, with per-sentence navigation and skip controls +- 📄 **Document formats**: EPUB, PDF, TXT, MD, DOCX (with libreoffice installed, plus hardened DOCX→PDF conversion for better reliability) - 🎨 **Customizable Experience**: - 🔑 Select TTS provider (OpenAI, Deepinfra, or Custom OpenAI-compatible) - 🔐 Set TTS API base URL and optional API key - 🎨 Multiple app theme options - And more... -### 🛠️ Work in progress -- [ ] **Native .docx support** (currently requires libreoffice) -- [ ] **Accessibility Improvements** +
+ + +### 🆕 What's New in v1.0.0 + + + +- 🧠 **Smart sentence continuation** + - EPUB and PDF playback now use smarter sentence splitting and continuation metadata so sentences that cross page/chapter boundaries are merged before hitting the TTS API. + - This yields more natural narration and fewer awkward pauses when a sentence spans multiple pages or EPUB spine items +- 🎧 **Chapter/page-based audiobook export with resume & regeneration** + - Per-chapter/per-page generation to disk with persistent `bookId` + - Resumable generation (can cancel and continue later) + - Per-chapter regeneration & deletion + - Final combined **M4B** or **MP3** download with embedded chapter metadata. +- 💾 **Dexie-backed local storage & sync** + - All document types (PDF, EPUB, TXT/MD-as-HTML) and config are stored via a unified Dexie layer on top of IndexedDB. + - Document lists use live Dexie queries (no manual refresh needed), and server sync now correctly includes text/markdown documents as part of the library backup. +- 🗣️ **Kokoro multi-voice selection & utilities** + - Kokoro models now support multi-voice combination, with provider-aware limits and helpers (not supported on OpenAI or Deepinfra) +- ⚡ **Faster, more efficient TTS backend proxy** + - In-memory **LRU caching** for audio responses with configurable size/TTL + - **ETag** support (`304` on cache hits) + `X-Cache` headers (`HIT` / `MISS` / `INFLIGHT`) +- 📄 **More robust DOCX → PDF conversion** + - DOCX conversion now uses isolated per-job LibreOffice profiles and temp directories, polls for a stable output file size, and aggressively cleans up temp files. + - This reduces cross-job interference and flakiness when converting multiple DOCX files in parallel. +- ♿ **Accessibility & layout improvements** + - Dialogs and folder toggles expose proper roles and ARIA attributes. + - PDF/EPUB/HTML readers use a full-height app shell with a sticky bottom TTS bar, improved scrollbars, and refined focus styles. +- ✅ **End-to-end Playwright test suite with TTS mocks** + - Deterministic TTS responses in tests via a reusable Playwright route mock. + - Coverage for accessibility, upload, navigation, folder management, deletion flows, and playback across all document types. + +
## 🐳 Docker Quick Start @@ -78,12 +113,18 @@ docker pull ghcr.io/richardr1126/openreader-webui:latest ### 🗣️ Local Kokoro-FastAPI Quick-start (CPU or GPU) -You can run the Kokoro TTS API server directly with Docker. **We are not responsible for issues with Kokoro-FastAPI.** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version). +You can run the Kokoro TTS API server directly with Docker. **We are not responsible for issues with [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI).** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version). > **Note:** When using these, set the `API_BASE` env var to `http://host.docker.internal:8880/v1` or `http://kokoro-tts:8880/v1`. > You can also use the example `docker-compose.yml` in `examples/docker-compose.yml` if you prefer Docker Compose. -**CPU Version:** +
+ + +**Docker CPU** + + + ```bash docker run -d \ --name kokoro-tts \ @@ -99,7 +140,15 @@ docker run -d \ ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4 ``` -**GPU Version:** +
+ +
+ + +**Docker GPU** + + + ```bash docker run -d \ --name kokoro-tts \ @@ -113,23 +162,31 @@ docker run -d \ ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.4 ``` +
+ > **Note:** > - These commands are for running the Kokoro TTS API server only. For issues or support, see the [Kokoro-FastAPI repository](https://github.com/remsky/Kokoro-FastAPI). > - The GPU version requires NVIDIA Docker support and works best with NVIDIA GPUs. The CPU version works best on Apple Silicon or modern x86 CPUs. > - Adjust environment variables as needed for your hardware and use case. -## Dev Installation +## Local Development Installation ### Prerequisites -- Node.js & npm or pnpm (recommended: use [nvm](https://github.com/nvm-sh/nvm) for Node.js) +- Node.js (recommended: use [nvm](https://github.com/nvm-sh/nvm)) +- pnpm (recommended) or npm + ```bash + npm install -g pnpm + ``` +- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible Optionally required for different features: - [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only) - - On Linux: `sudo apt install ffmpeg` - - On MacOS: `brew install ffmpeg` + ```bash + brew install ffmpeg + ``` - [libreoffice](https://www.libreoffice.org) (required for DOCX files) - - On Linux: `sudo apt install libreoffice` - - On MacOS: `brew install libreoffice` - + ```bash + brew install libreoffice + ``` ### Steps 1. Clone the repository: @@ -142,12 +199,7 @@ Optionally required for different features: With pnpm (recommended): ```bash - pnpm install - ``` - - Or with npm: - ```bash - npm install + pnpm i # or npm i ``` 3. Configure the environment: @@ -161,26 +213,15 @@ Optionally required for different features: With pnpm (recommended): ```bash - pnpm dev - ``` - - Or with npm: - ```bash - npm run dev + pnpm dev # or npm run dev ``` or build and run the production server: With pnpm: ```bash - pnpm build - pnpm start - ``` - - Or with npm: - ```bash - npm run build - npm start + pnpm build # or npm run build + pnpm start # or npm start ``` Visit [http://localhost:3003](http://localhost:3003) to run the app. @@ -217,7 +258,7 @@ This project would not be possible without standing on the shoulders of these gi - **Framework:** Next.js (React) - **Containerization:** Docker -- **Storage:** IndexedDB (in browser db store) +- **Storage:** Dexie + IndexedDB (in-browser local database) - **PDF:** - [react-pdf](https://github.com/wojtekmaj/react-pdf) - [pdf.js](https://mozilla.github.io/pdf.js/) diff --git a/package.json b/package.json index 026ce35..7fc2a3b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "cmpstr": "^3.0.4", "compromise": "^14.14.4", "core-js": "^3.46.0", + "dexie": "^4.2.1", + "dexie-react-hooks": "^4.2.0", "epubjs": "^0.3.93", "howler": "^2.2.4", "lru-cache": "^11.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bbecc8..a7a9ec6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: core-js: specifier: ^3.46.0 version: 3.46.0 + dexie: + specifier: ^4.2.1 + version: 4.2.1 + dexie-react-hooks: + specifier: ^4.2.0 + version: 4.2.0(@types/react@19.2.2)(dexie@4.2.1)(react@19.2.0) epubjs: specifier: ^0.3.93 version: 0.3.93 @@ -1093,6 +1099,16 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dexie-react-hooks@4.2.0: + resolution: {integrity: sha512-u7KqTX9JpBQK8+tEyA9X0yMGXlSCsbm5AU64N6gjvGk/IutYDpLBInMYEAEC83s3qhIvryFS+W+sqLZUBEvePQ==} + peerDependencies: + '@types/react': '>=16' + dexie: '>=4.2.0-alpha.1 <5.0.0' + react: '>=16' + + dexie@4.2.1: + resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -3710,6 +3726,14 @@ snapshots: dependencies: dequal: 2.0.3 + dexie-react-hooks@4.2.0(@types/react@19.2.2)(dexie@4.2.1)(react@19.2.0): + dependencies: + '@types/react': 19.2.2 + dexie: 4.2.1 + react: 19.2.0 + + dexie@4.2.1: {} + didyoumean@1.2.2: {} dlv@1.1.3: {} diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 679b0ea..f926e2f 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -1,6 +1,7 @@ import { writeFile, readFile, readdir, mkdir, unlink } from 'fs/promises'; import { NextRequest, NextResponse } from 'next/server'; import path from 'path'; +import type { BaseDocument, SyncedDocument } from '@/types/documents'; const DOCS_DIR = path.join(process.cwd(), 'docstore'); @@ -17,9 +18,10 @@ export async function POST(req: NextRequest) { try { await ensureDocsDir(); const data = await req.json(); + const documents = data.documents as SyncedDocument[]; // Save document metadata and content - for (const doc of data.documents) { + for (const doc of documents) { const docPath = path.join(DOCS_DIR, `${doc.id}.json`); const contentPath = path.join(DOCS_DIR, `${doc.id}.${doc.type}`); @@ -49,7 +51,7 @@ export async function POST(req: NextRequest) { export async function GET() { try { await ensureDocsDir(); - const documents = []; + const documents: SyncedDocument[] = []; const files = await readdir(DOCS_DIR); const jsonFiles = files.filter(file => file.endsWith('.json')); @@ -58,7 +60,7 @@ export async function GET() { const docPath = path.join(DOCS_DIR, file); try { - const metadata = JSON.parse(await readFile(docPath, 'utf8')); + const metadata = JSON.parse(await readFile(docPath, 'utf8')) as BaseDocument; const contentPath = path.join(DOCS_DIR, `${metadata.id}.${metadata.type}`); const content = await readFile(contentPath); diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index a6a2ec1..2e0dade 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -23,36 +23,6 @@ const ttsAudioCache = new LRUCache({ ttl: TTS_CACHE_TTL_MS, }); -// Concurrency controls and in-flight de-duplication -const TTS_MAX_CONCURRENCY = Number(process.env.TTS_MAX_CONCURRENCY || 4); - -class Semaphore { - private permits: number; - private queue: Array<() => void> = []; - constructor(max: number) { - this.permits = Math.max(1, max); - } - async acquire(): Promise<() => void> { - if (this.permits > 0) { - this.permits -= 1; - return this.release.bind(this); - } - return new Promise<() => void>((resolve) => { - this.queue.push(() => { - this.permits -= 1; - resolve(this.release.bind(this)); - }); - }); - } - private release() { - this.permits += 1; - const next = this.queue.shift(); - if (next) next(); - } -} - -const ttsSemaphore = new Semaphore(TTS_MAX_CONCURRENCY); - type InflightEntry = { promise: Promise; controller: AbortController; @@ -211,7 +181,7 @@ export async function POST(req: NextRequest) { }); } - // De-duplicate identical in-flight requests and bound upstream concurrency + // De-duplicate identical in-flight requests const existing = inflightRequests.get(cacheKey); if (existing) { console.log('TTS in-flight JOIN for key:', cacheKey.slice(0, 8)); @@ -247,14 +217,12 @@ export async function POST(req: NextRequest) { controller, consumers: 1, promise: (async () => { - const release = await ttsSemaphore.acquire(); try { const buffer = await fetchTTSBufferWithRetry(openai, createParams, controller.signal); // Save to cache ttsAudioCache.set(cacheKey, buffer); return buffer; } finally { - release(); inflightRequests.delete(cacheKey); } })() diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index bc32181..fa1f78f 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -22,9 +22,8 @@ import { import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; -import { indexedDBService } from '@/utils/indexedDB'; +import { syncDocumentsToServer, loadDocumentsFromServer, setItem, getItem } from '@/utils/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; -import { setItem, getItem } from '@/utils/indexedDB'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; @@ -42,7 +41,7 @@ export function SettingsModal() { const { theme, setTheme } = useTheme(); const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); - const { refreshPDFs, refreshEPUBs, clearPDFs, clearEPUBs } = useDocuments(); + const { clearPDFs, clearEPUBs, clearHTML } = useDocuments(); const [localApiKey, setLocalApiKey] = useState(apiKey); const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl); const [localTTSProvider, setLocalTTSProvider] = useState(ttsProvider); @@ -158,7 +157,7 @@ export function SettingsModal() { setProgress(0); setOperationType('sync'); setStatusMessage('Preparing documents...'); - await indexedDBService.syncToServer((progress, status) => { + await syncDocumentsToServer((progress, status) => { if (controller.signal.aborted) return; setProgress(progress); if (status) setStatusMessage(status); @@ -190,14 +189,13 @@ export function SettingsModal() { setProgress(0); setOperationType('load'); setStatusMessage('Downloading documents from server...'); - await indexedDBService.loadFromServer((progress, status) => { + await loadDocumentsFromServer((progress, status) => { if (controller.signal.aborted) return; setProgress(progress); if (status) setStatusMessage(status); }, controller.signal); if (controller.signal.aborted) return; - setStatusMessage('Refreshing document list...'); - await Promise.all([refreshPDFs(), refreshEPUBs()]); + setStatusMessage('Documents loaded from server'); } catch (error) { if (controller.signal.aborted) { console.log('Load operation cancelled'); @@ -218,6 +216,7 @@ export function SettingsModal() { const handleClearLocal = async () => { await clearPDFs(); await clearEPUBs(); + await clearHTML(); setShowClearLocalConfirm(false); }; diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index c28f433..0878620 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -5,7 +5,7 @@ import { useDocuments } from '@/contexts/DocumentContext'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { DocumentType, DocumentListDocument, Folder, DocumentListState, SortBy, SortDirection } from '@/types/documents'; -import { getDocumentListState, saveDocumentListState } from '@/utils/indexedDB'; +import { getDocumentListState, saveDocumentListState } from '@/utils/dexie'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { DocumentListItem } from '@/components/doclist/DocumentListItem'; import { DocumentFolder } from '@/components/doclist/DocumentFolder'; diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index f99441d..8b6f3c3 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -1,7 +1,7 @@ 'use client'; import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; -import { getItem, indexedDBService, setItem, removeItem } from '@/utils/indexedDB'; +import { getItem, setItem, removeItem, initDB } from '@/utils/dexie'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -95,7 +95,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const initializeDB = async () => { try { setIsLoading(true); - await indexedDBService.init(); + await initDB(); setIsDBReady(true); // Load config from IndexedDB diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index adc6b77..597b235 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -25,10 +25,6 @@ interface DocumentContextType { removeHTMLDocument: (id: string) => Promise; isHTMLLoading: boolean; - refreshPDFs: () => Promise; - refreshEPUBs: () => Promise; - refreshHTML: () => Promise; - clearPDFs: () => Promise; clearEPUBs: () => Promise; clearHTML: () => Promise; @@ -42,7 +38,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) { addDocument: addPDFDocument, removeDocument: removePDFDocument, isLoading: isPDFLoading, - refresh: refreshPDFs, clearDocuments: clearPDFs } = usePDFDocuments(); @@ -51,7 +46,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) { addDocument: addEPUBDocument, removeDocument: removeEPUBDocument, isLoading: isEPUBLoading, - refresh: refreshEPUBs, clearDocuments: clearEPUBs } = useEPUBDocuments(); @@ -60,7 +54,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) { addDocument: addHTMLDocument, removeDocument: removeHTMLDocument, isLoading: isHTMLLoading, - refresh: refreshHTML, clearDocuments: clearHTML } = useHTMLDocuments(); @@ -78,9 +71,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) { addHTMLDocument, removeHTMLDocument, isHTMLLoading, - refreshPDFs, - refreshEPUBs, - refreshHTML, clearPDFs, clearEPUBs, clearHTML diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index ebdd451..25d849f 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -10,12 +10,11 @@ import { useRef, RefObject } from 'react'; -import { indexedDBService } from '@/utils/indexedDB'; +import { getEpubDocument, setLastDocumentLocation } from '@/utils/dexie'; import { useTTS } from '@/contexts/TTSContext'; import { Book, Rendition } from 'epubjs'; import { createRangeCfi } from '@/utils/epub'; import type { NavItem } from 'epubjs'; -import { setLastDocumentLocation } from '@/utils/indexedDB'; import { SpineItem } from 'epubjs/types/section'; import { useParams } from 'next/navigation'; import { useConfig } from './ConfigContext'; @@ -176,7 +175,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { */ const setCurrentDocument = useCallback(async (id: string): Promise => { try { - const doc = await indexedDBService.getEPUBDocument(id); + const doc = await getEpubDocument(id); if (doc) { console.log('Retrieved document size:', doc.size); console.log('Retrieved ArrayBuffer size:', doc.data.byteLength); diff --git a/src/contexts/HTMLContext.tsx b/src/contexts/HTMLContext.tsx index e2752bd..81d83d6 100644 --- a/src/contexts/HTMLContext.tsx +++ b/src/contexts/HTMLContext.tsx @@ -8,7 +8,7 @@ import { useCallback, useMemo, } from 'react'; -import { indexedDBService } from '@/utils/indexedDB'; +import { getHtmlDocument } from '@/utils/dexie'; import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { combineAudioChunks, withRetry } from '@/utils/audio'; @@ -58,7 +58,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) { */ const setCurrentDocument = useCallback(async (id: string): Promise => { try { - const doc = await indexedDBService.getHTMLDocument(id); + const doc = await getHtmlDocument(id); if (doc) { setCurrDocName(doc.name); setCurrDocData(doc.data); diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 4128816..b54bd43 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -25,7 +25,7 @@ import { useRef, } from 'react'; -import { indexedDBService } from '@/utils/indexedDB'; +import { getPdfDocument } from '@/utils/dexie'; import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { @@ -215,7 +215,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { */ const setCurrentDocument = useCallback(async (id: string): Promise => { try { - const doc = await indexedDBService.getDocument(id); + const doc = await getPdfDocument(id); if (doc) { setCurrDocName(doc.name); setCurrDocData(doc.data); diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 8644d88..477d81f 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -34,7 +34,7 @@ import { useAudioCache } from '@/hooks/audio/useAudioCache'; import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement'; import { useMediaSession } from '@/hooks/audio/useMediaSession'; import { useAudioContext } from '@/hooks/audio/useAudioContext'; -import { getLastDocumentLocation, setLastDocumentLocation } from '@/utils/indexedDB'; +import { getLastDocumentLocation, setLastDocumentLocation } from '@/utils/dexie'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { withRetry } from '@/utils/audio'; import { preprocessSentenceForAudio, processTextToSentences } from '@/utils/nlp'; diff --git a/src/hooks/epub/useEPUBDocuments.ts b/src/hooks/epub/useEPUBDocuments.ts index c0cc993..1098383 100644 --- a/src/hooks/epub/useEPUBDocuments.ts +++ b/src/hooks/epub/useEPUBDocuments.ts @@ -1,40 +1,27 @@ 'use client'; -import { useState, useCallback, useEffect } from 'react'; +import { useCallback } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import { indexedDBService } from '@/utils/indexedDB'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { db } from '@/utils/dexie'; import type { EPUBDocument } from '@/types/documents'; -import { useConfig } from '@/contexts/ConfigContext'; export function useEPUBDocuments() { - const { isDBReady } = useConfig(); - const [documents, setDocuments] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const documents = useLiveQuery( + () => db['epub-documents'].toArray(), + [], + undefined, + ); - const loadDocuments = useCallback(async () => { - if (isDBReady) { - try { - const docs = await indexedDBService.getAllEPUBDocuments(); - setDocuments(docs); - } catch (error) { - console.error('Failed to load EPUB documents:', error); - } finally { - setIsLoading(false); - } - } - }, [isDBReady]); - - useEffect(() => { - loadDocuments(); - }, [loadDocuments]); + const isLoading = documents === undefined; const addDocument = useCallback(async (file: File): Promise => { const id = uuidv4(); const arrayBuffer = await file.arrayBuffer(); - + console.log('Original file size:', file.size); console.log('ArrayBuffer size:', arrayBuffer.byteLength); - + const newDoc: EPUBDocument = { id, type: 'epub', @@ -44,56 +31,23 @@ export function useEPUBDocuments() { data: arrayBuffer, }; - try { - await indexedDBService.addEPUBDocument(newDoc); - setDocuments((prev) => [...prev, newDoc]); - return id; - } catch (error) { - console.error('Failed to add EPUB document:', error); - throw error; - } + await db['epub-documents'].add(newDoc); + return id; }, []); const removeDocument = useCallback(async (id: string): Promise => { - try { - await indexedDBService.removeEPUBDocument(id); - setDocuments((prev) => prev.filter((doc) => doc.id !== id)); - } catch (error) { - console.error('Failed to remove EPUB document:', error); - throw error; - } + await db['epub-documents'].delete(id); }, []); - const refresh = useCallback(async () => { - if (isDBReady) { - setIsLoading(true); - try { - const docs = await indexedDBService.getAllEPUBDocuments(); - setDocuments(docs); - } catch (error) { - console.error('Failed to refresh documents:', error); - } finally { - setIsLoading(false); - } - } - }, [isDBReady]); - const clearDocuments = useCallback(async (): Promise => { - try { - await indexedDBService.clearEPUBDocuments(); - setDocuments([]); - } catch (error) { - console.error('Failed to clear EPUB documents:', error); - throw error; - } + await db['epub-documents'].clear(); }, []); return { - documents, + documents: documents ?? [], isLoading, addDocument, removeDocument, - refresh, - clearDocuments, // Add clearDocuments to return value + clearDocuments, }; } diff --git a/src/hooks/html/useHTMLDocuments.ts b/src/hooks/html/useHTMLDocuments.ts index 11b8ed5..a51ef9c 100644 --- a/src/hooks/html/useHTMLDocuments.ts +++ b/src/hooks/html/useHTMLDocuments.ts @@ -1,32 +1,19 @@ 'use client'; -import { useState, useCallback, useEffect } from 'react'; +import { useCallback } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import { indexedDBService } from '@/utils/indexedDB'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { db } from '@/utils/dexie'; import type { HTMLDocument } from '@/types/documents'; -import { useConfig } from '@/contexts/ConfigContext'; export function useHTMLDocuments() { - const { isDBReady } = useConfig(); - const [documents, setDocuments] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const documents = useLiveQuery( + () => db['html-documents'].toArray(), + [], + undefined, + ); - const loadDocuments = useCallback(async () => { - if (isDBReady) { - try { - const docs = await indexedDBService.getAllHTMLDocuments(); - setDocuments(docs); - } catch (error) { - console.error('Failed to load HTML documents:', error); - } finally { - setIsLoading(false); - } - } - }, [isDBReady]); - - useEffect(() => { - loadDocuments(); - }, [loadDocuments]); + const isLoading = documents === undefined; const addDocument = useCallback(async (file: File): Promise => { const id = uuidv4(); @@ -41,56 +28,23 @@ export function useHTMLDocuments() { data: content, }; - try { - await indexedDBService.addHTMLDocument(newDoc); - setDocuments((prev) => [...prev, newDoc]); - return id; - } catch (error) { - console.error('Failed to add HTML document:', error); - throw error; - } + await db['html-documents'].add(newDoc); + return id; }, []); const removeDocument = useCallback(async (id: string): Promise => { - try { - await indexedDBService.removeHTMLDocument(id); - setDocuments((prev) => prev.filter((doc) => doc.id !== id)); - } catch (error) { - console.error('Failed to remove HTML document:', error); - throw error; - } + await db['html-documents'].delete(id); }, []); - const refresh = useCallback(async () => { - if (isDBReady) { - setIsLoading(true); - try { - const docs = await indexedDBService.getAllHTMLDocuments(); - setDocuments(docs); - } catch (error) { - console.error('Failed to refresh documents:', error); - } finally { - setIsLoading(false); - } - } - }, [isDBReady]); - const clearDocuments = useCallback(async (): Promise => { - try { - await indexedDBService.clearHTMLDocuments(); - setDocuments([]); - } catch (error) { - console.error('Failed to clear HTML documents:', error); - throw error; - } + await db['html-documents'].clear(); }, []); return { - documents, + documents: documents ?? [], isLoading, addDocument, removeDocument, - refresh, clearDocuments, }; } diff --git a/src/hooks/pdf/usePDFDocuments.ts b/src/hooks/pdf/usePDFDocuments.ts index e2fa4ea..57bb6c5 100644 --- a/src/hooks/pdf/usePDFDocuments.ts +++ b/src/hooks/pdf/usePDFDocuments.ts @@ -1,46 +1,24 @@ 'use client'; -import { useState, useCallback, useEffect } from 'react'; +import { useCallback } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import { indexedDBService } from '@/utils/indexedDB'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { db } from '@/utils/dexie'; import type { PDFDocument } from '@/types/documents'; -import { useConfig } from '@/contexts/ConfigContext'; export function usePDFDocuments() { - const { isDBReady } = useConfig(); - const [documents, setDocuments] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const documents = useLiveQuery( + () => db['pdf-documents'].toArray(), + [], + undefined, + ); - /** - * Load documents from IndexedDB when the database is ready - */ - const loadDocuments = useCallback(async () => { - if (isDBReady) { - try { - const docs = await indexedDBService.getAllDocuments(); - setDocuments(docs); - } catch (error) { - console.error('Failed to load documents:', error); - } finally { - setIsLoading(false); - } - } - }, [isDBReady]); + const isLoading = documents === undefined; - useEffect(() => { - loadDocuments(); - }, [loadDocuments]); - - /** - * Adds a new document to IndexedDB - * - * @param {File} file - The PDF file to add - * @returns {Promise} The ID of the added document - */ const addDocument = useCallback(async (file: File): Promise => { const id = uuidv4(); const arrayBuffer = await file.arrayBuffer(); - + const newDoc: PDFDocument = { id, type: 'pdf', @@ -50,61 +28,23 @@ export function usePDFDocuments() { data: arrayBuffer, }; - try { - await indexedDBService.addDocument(newDoc); - setDocuments((prev) => [...prev, newDoc]); - return id; - } catch (error) { - console.error('Failed to add document:', error); - throw error; - } + await db['pdf-documents'].add(newDoc); + return id; }, []); - /** - * Removes a document from IndexedDB - * - * @param {string} id - The ID of the document to remove - */ const removeDocument = useCallback(async (id: string): Promise => { - try { - await indexedDBService.removeDocument(id); - setDocuments((prev) => prev.filter((doc) => doc.id !== id)); - } catch (error) { - console.error('Failed to remove document:', error); - throw error; - } + await db['pdf-documents'].delete(id); }, []); - const refresh = useCallback(async () => { - if (isDBReady) { - setIsLoading(true); - try { - const docs = await indexedDBService.getAllDocuments(); - setDocuments(docs); - } catch (error) { - console.error('Failed to refresh documents:', error); - } finally { - setIsLoading(false); - } - } - }, [isDBReady]); - const clearDocuments = useCallback(async (): Promise => { - try { - await indexedDBService.clearPDFDocuments(); - setDocuments([]); - } catch (error) { - console.error('Failed to clear PDF documents:', error); - throw error; - } + await db['pdf-documents'].clear(); }, []); return { - documents, + documents: documents ?? [], isLoading, addDocument, removeDocument, - refresh, - clearDocuments, // Add clearDocuments to return value + clearDocuments, }; } diff --git a/src/types/documents.ts b/src/types/documents.ts index 397e1c2..d21b1af 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -30,6 +30,20 @@ export interface DOCXDocument extends BaseDocument { data: ArrayBuffer; } +export type AnyDocument = + | PDFDocument + | EPUBDocument + | HTMLDocument + | DOCXDocument; + +export type BinaryDocument = PDFDocument | EPUBDocument | DOCXDocument; + +// Representation used when syncing binary documents to/from the server. +// Data is converted from ArrayBuffer to a numeric array for JSON transport. +export interface SyncedDocument extends BaseDocument { + data: number[]; +} + export interface DocumentListDocument extends BaseDocument { type: DocumentType; } diff --git a/src/utils/dexie.ts b/src/utils/dexie.ts new file mode 100644 index 0000000..ab97169 --- /dev/null +++ b/src/utils/dexie.ts @@ -0,0 +1,413 @@ +import Dexie, { type EntityTable } from 'dexie'; +import { + PDFDocument, + EPUBDocument, + HTMLDocument, + DocumentListState, + SyncedDocument, +} from '@/types/documents'; + +const DB_NAME = 'openreader-db'; +// Managed via Dexie (version bumped from the original manual IndexedDB) +const DB_VERSION = 4; + +const PDF_TABLE = 'pdf-documents' as const; +const EPUB_TABLE = 'epub-documents' as const; +const HTML_TABLE = 'html-documents' as const; +const CONFIG_TABLE = 'config' as const; + +export interface ConfigRow { + key: string; + value: string; +} + +type OpenReaderDB = Dexie & { + [PDF_TABLE]: EntityTable; + [EPUB_TABLE]: EntityTable; + [HTML_TABLE]: EntityTable; + [CONFIG_TABLE]: EntityTable; +}; + +export const db = new Dexie(DB_NAME) as OpenReaderDB; + +db.version(DB_VERSION).stores({ + [PDF_TABLE]: 'id, type, name, lastModified, size, folderId', + [EPUB_TABLE]: 'id, type, name, lastModified, size, folderId', + [HTML_TABLE]: 'id, type, name, lastModified, size, folderId', + [CONFIG_TABLE]: 'key', +}); + +let dbOpenPromise: Promise | null = null; + +export async function initDB(): Promise { + if (dbOpenPromise) { + return dbOpenPromise; + } + + dbOpenPromise = (async () => { + try { + console.log('Opening Dexie database...'); + await db.open(); + console.log('Dexie database opened successfully'); + } catch (error) { + console.error('Dexie initialization error:', error); + dbOpenPromise = null; + throw error; + } + })(); + + return dbOpenPromise; +} + +async function withDB(operation: () => Promise): Promise { + await initDB(); + return operation(); +} + +// PDF document helpers + +export async function addPdfDocument(document: PDFDocument): Promise { + await withDB(async () => { + console.log('Adding PDF document via Dexie:', document.name); + await db[PDF_TABLE].put(document); + }); +} + +export async function getPdfDocument(id: string): Promise { + return withDB(async () => { + console.log('Fetching PDF document via Dexie:', id); + return db[PDF_TABLE].get(id); + }); +} + +export async function getAllPdfDocuments(): Promise { + return withDB(async () => { + console.log('Fetching all PDF documents via Dexie'); + return db[PDF_TABLE].toArray(); + }); +} + +export async function removePdfDocument(id: string): Promise { + await withDB(async () => { + console.log('Removing PDF document via Dexie:', id); + const locationKey = `lastLocation_${id}`; + await db.transaction('readwrite', db[PDF_TABLE], db[CONFIG_TABLE], async () => { + await db[PDF_TABLE].delete(id); + await db[CONFIG_TABLE].delete(locationKey); + }); + }); +} + +export async function clearPdfDocuments(): Promise { + await withDB(async () => { + console.log('Clearing all PDF documents via Dexie'); + await db[PDF_TABLE].clear(); + }); +} + +// EPUB document helpers + +export async function addEpubDocument(document: EPUBDocument): Promise { + await withDB(async () => { + if (document.data.byteLength === 0) { + throw new Error('Cannot store empty ArrayBuffer'); + } + + console.log('Adding EPUB document via Dexie:', { + name: document.name, + size: document.size, + actualSize: document.data.byteLength, + }); + + await db[EPUB_TABLE].put(document); + }); +} + +export async function getEpubDocument(id: string): Promise { + return withDB(async () => { + console.log('Fetching EPUB document via Dexie:', id); + return db[EPUB_TABLE].get(id); + }); +} + +export async function getAllEpubDocuments(): Promise { + return withDB(async () => { + console.log('Fetching all EPUB documents via Dexie'); + return db[EPUB_TABLE].toArray(); + }); +} + +export async function removeEpubDocument(id: string): Promise { + await withDB(async () => { + console.log('Removing EPUB document via Dexie:', id); + const locationKey = `lastLocation_${id}`; + await db.transaction('readwrite', db[EPUB_TABLE], db[CONFIG_TABLE], async () => { + await db[EPUB_TABLE].delete(id); + await db[CONFIG_TABLE].delete(locationKey); + }); + }); +} + +export async function clearEpubDocuments(): Promise { + await withDB(async () => { + console.log('Clearing all EPUB documents via Dexie'); + await db[EPUB_TABLE].clear(); + }); +} + +// HTML / text document helpers + +export async function addHtmlDocument(document: HTMLDocument): Promise { + await withDB(async () => { + console.log('Adding HTML document via Dexie:', document.name); + await db[HTML_TABLE].put(document); + }); +} + +export async function getHtmlDocument(id: string): Promise { + return withDB(async () => { + console.log('Fetching HTML document via Dexie:', id); + return db[HTML_TABLE].get(id); + }); +} + +export async function getAllHtmlDocuments(): Promise { + return withDB(async () => { + console.log('Fetching all HTML documents via Dexie'); + return db[HTML_TABLE].toArray(); + }); +} + +export async function removeHtmlDocument(id: string): Promise { + await withDB(async () => { + console.log('Removing HTML document via Dexie:', id); + await db[HTML_TABLE].delete(id); + }); +} + +export async function clearHtmlDocuments(): Promise { + await withDB(async () => { + console.log('Clearing all HTML documents via Dexie'); + await db[HTML_TABLE].clear(); + }); +} + +// Config helpers + +export async function setConfigItem(key: string, value: string): Promise { + await withDB(async () => { + console.log('Setting config item via Dexie:', key); + await db[CONFIG_TABLE].put({ key, value }); + }); +} + +export async function getConfigItem(key: string): Promise { + const row = await withDB(() => db[CONFIG_TABLE].get(key)); + console.log('Fetching config item via Dexie:', key, row ? 'found' : 'not found'); + return row ? row.value : null; +} + +export async function getAllConfigItems(): Promise> { + const rows = await withDB(() => db[CONFIG_TABLE].toArray()); + const config: Record = {}; + rows.forEach((row) => { + config[row.key] = row.value; + }); + console.log('Fetched config items via Dexie:', rows.length); + return config; +} + +export async function removeConfigItem(key: string): Promise { + await withDB(async () => { + console.log('Removing config item via Dexie:', key); + await db[CONFIG_TABLE].delete(key); + }); +} + +// Legacy-style config accessors retained for convenience + +export const getItem = getConfigItem; +export const setItem = setConfigItem; +export const removeItem = removeConfigItem; + +// Document list state helpers + +export async function saveDocumentListState(state: DocumentListState): Promise { + await setConfigItem('documentListState', JSON.stringify(state)); +} + +export async function getDocumentListState(): Promise { + const stateStr = await getConfigItem('documentListState'); + if (!stateStr) return null; + try { + return JSON.parse(stateStr); + } catch (error) { + console.error('Error parsing document list state:', error); + return null; + } +} + +// Last-location helpers (used by TTS and readers) + +export async function getLastDocumentLocation(docId: string): Promise { + const key = `lastLocation_${docId}`; + return getConfigItem(key); +} + +export async function setLastDocumentLocation(docId: string, location: string): Promise { + const key = `lastLocation_${docId}`; + await setConfigItem(key, location); +} + +// Sync helpers (server round-trip) + +export async function syncDocumentsToServer( + onProgress?: (progress: number, status?: string) => void, + signal?: AbortSignal, +): Promise<{ lastSync: number }> { + const pdfDocs = await getAllPdfDocuments(); + const epubDocs = await getAllEpubDocuments(); + const htmlDocs = await getAllHtmlDocuments(); + + const documents: SyncedDocument[] = []; + const totalDocs = pdfDocs.length + epubDocs.length + htmlDocs.length; + let processedDocs = 0; + + for (const doc of pdfDocs) { + documents.push({ + ...doc, + type: 'pdf', + data: Array.from(new Uint8Array(doc.data)), + }); + processedDocs++; + if (onProgress) { + onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`); + } + } + + for (const doc of epubDocs) { + documents.push({ + ...doc, + type: 'epub', + data: Array.from(new Uint8Array(doc.data)), + }); + processedDocs++; + if (onProgress) { + onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`); + } + } + + const encoder = new TextEncoder(); + for (const doc of htmlDocs) { + const encoded = encoder.encode(doc.data); + documents.push({ + ...doc, + type: 'html', + data: Array.from(encoded), + }); + processedDocs++; + if (onProgress) { + onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`); + } + } + + if (onProgress) { + onProgress(50, 'Uploading to server...'); + } + + const response = await fetch('/api/documents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ documents }), + signal, + }); + + if (!response.ok) { + throw new Error('Failed to sync documents to server'); + } + + if (onProgress) { + onProgress(100, 'Upload complete!'); + } + + return { lastSync: Date.now() }; +} + +export async function loadDocumentsFromServer( + onProgress?: (progress: number, status?: string) => void, + signal?: AbortSignal, +): Promise<{ lastSync: number }> { + if (onProgress) { + onProgress(10, 'Starting download...'); + } + + const response = await fetch('/api/documents', { signal }); + if (!response.ok) { + throw new Error('Failed to fetch documents from server'); + } + + if (onProgress) { + onProgress(30, 'Download complete'); + } + + const { documents } = (await response.json()) as { documents: SyncedDocument[] }; + + if (onProgress) { + onProgress(40, 'Parsing documents...'); + } + + const textDecoder = new TextDecoder(); + + for (let i = 0; i < documents.length; i++) { + const doc = documents[i]; + + if (doc.type === 'pdf') { + const uint8Array = new Uint8Array(doc.data); + const documentData: PDFDocument = { + id: doc.id, + type: 'pdf', + name: doc.name, + size: doc.size, + lastModified: doc.lastModified, + data: uint8Array.buffer, + }; + await addPdfDocument(documentData); + } else if (doc.type === 'epub') { + const uint8Array = new Uint8Array(doc.data); + const documentData: EPUBDocument = { + id: doc.id, + type: 'epub', + name: doc.name, + size: doc.size, + lastModified: doc.lastModified, + data: uint8Array.buffer, + }; + await addEpubDocument(documentData); + } else if (doc.type === 'html') { + const uint8Array = new Uint8Array(doc.data); + const decoded = textDecoder.decode(uint8Array); + const documentData: HTMLDocument = { + id: doc.id, + type: 'html', + name: doc.name, + size: doc.size, + lastModified: doc.lastModified, + data: decoded, + }; + await addHtmlDocument(documentData); + } else { + console.warn(`Unknown document type: ${doc.type}`); + } + + if (onProgress) { + onProgress(40 + ((i + 1) / documents.length) * 50, `Processing document ${i + 1}/${documents.length}...`); + } + } + + if (onProgress) { + onProgress(100, 'Load complete!'); + } + + return { lastSync: Date.now() }; +} + diff --git a/src/utils/indexedDB.ts b/src/utils/indexedDB.ts deleted file mode 100644 index 973df19..0000000 --- a/src/utils/indexedDB.ts +++ /dev/null @@ -1,857 +0,0 @@ -import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState } from '@/types/documents'; - -const DB_NAME = 'openreader-db'; -const DB_VERSION = 3; // Increased version for new store -const PDF_STORE_NAME = 'pdf-documents'; -const EPUB_STORE_NAME = 'epub-documents'; -const HTML_STORE_NAME = 'html-documents'; -const CONFIG_STORE_NAME = 'config'; - -export interface Config { - key: string; - value: string; -} - -class IndexedDBService { - private db: IDBDatabase | null = null; - private initPromise: Promise | null = null; - - async init(): Promise { - if (this.initPromise) { - return this.initPromise; - } - - if (this.db) { - return Promise.resolve(); - } - - this.initPromise = new Promise((resolve, reject) => { - console.log('Initializing IndexedDB...'); - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('IndexedDB initialization error:', error); - reject(error); - }; - - request.onsuccess = (event) => { - console.log('IndexedDB initialized successfully'); - this.db = (event.target as IDBOpenDBRequest).result; - resolve(); - }; - - request.onupgradeneeded = (event) => { - console.log('Upgrading IndexedDB schema...'); - const db = (event.target as IDBOpenDBRequest).result; - - if (!db.objectStoreNames.contains(PDF_STORE_NAME)) { - console.log('Creating PDF documents store...'); - db.createObjectStore(PDF_STORE_NAME, { keyPath: 'id' }); - } - - if (!db.objectStoreNames.contains(HTML_STORE_NAME)) { - console.log('Creating HTML documents store...'); - db.createObjectStore(HTML_STORE_NAME, { keyPath: 'id' }); - } - - if (!db.objectStoreNames.contains(EPUB_STORE_NAME)) { - console.log('Creating EPUB documents store...'); - db.createObjectStore(EPUB_STORE_NAME, { keyPath: 'id' }); - } - - if (!db.objectStoreNames.contains(CONFIG_STORE_NAME)) { - console.log('Creating config store...'); - db.createObjectStore(CONFIG_STORE_NAME, { keyPath: 'key' }); - } - }; - }); - - return this.initPromise; - } - - // PDF Document Methods - async addDocument(document: PDFDocument): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Adding document to IndexedDB:', document.name); - const transaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite'); - const store = transaction.objectStore(PDF_STORE_NAME); - - // Create a structured clone of the document to ensure proper storage - const request = store.put({ - ...document, - data: document.data // Store ArrayBuffer directly - }); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error adding document:', error); - reject(error); - }; - - transaction.oncomplete = () => { - console.log('Document added successfully:', document.name); - resolve(); - }; - } catch (error) { - console.error('Error in addDocument transaction:', error); - reject(error); - } - }); - } - - async getDocument(id: string): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Fetching document:', id); - const transaction = this.db!.transaction([PDF_STORE_NAME], 'readonly'); - const store = transaction.objectStore(PDF_STORE_NAME); - const request = store.get(id); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error fetching document:', error); - reject(error); - }; - - request.onsuccess = () => { - console.log('Document fetch result:', request.result ? 'found' : 'not found'); - resolve(request.result); - }; - } catch (error) { - console.error('Error in getDocument transaction:', error); - reject(error); - } - }); - } - - async getAllDocuments(): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Fetching all documents'); - const transaction = this.db!.transaction([PDF_STORE_NAME], 'readonly'); - const store = transaction.objectStore(PDF_STORE_NAME); - const request = store.getAll(); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error fetching all documents:', error); - reject(error); - }; - - request.onsuccess = () => { - console.log('Retrieved documents count:', request.result?.length || 0); - resolve(request.result || []); - }; - } catch (error) { - console.error('Error in getAllDocuments transaction:', error); - reject(error); - } - }); - } - - async removeDocument(id: string): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Removing document:', id); - const locationKey = `lastLocation_${id}`; - - // Create two transactions - one for document deletion, one for location cleanup - const docTransaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite'); - const configTransaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite'); - - const docStore = docTransaction.objectStore(PDF_STORE_NAME); - const configStore = configTransaction.objectStore(CONFIG_STORE_NAME); - - // Remove the document - const docRequest = docStore.delete(id); - // Remove any saved location - const locationRequest = configStore.delete(locationKey); - - docRequest.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error removing document:', error); - reject(error); - }; - - locationRequest.onerror = (event) => { - console.warn('Error cleaning up location:', (event.target as IDBRequest).error); - // Don't reject here, as document deletion is the primary operation - }; - - // Wait for both transactions to complete - Promise.all([ - new Promise((res) => { docTransaction.oncomplete = () => res(); }), - new Promise((res) => { configTransaction.oncomplete = () => res(); }) - ]).then(() => { - console.log('Document and location data removed successfully:', id); - resolve(); - }).catch(reject); - - } catch (error) { - console.error('Error in removeDocument transaction:', error); - reject(error); - } - }); - } - - // Add HTML Document Methods - async addHTMLDocument(document: HTMLDocument): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Adding HTML document to IndexedDB:', document.name); - const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite'); - const store = transaction.objectStore(HTML_STORE_NAME); - - const request = store.put({ - ...document, - data: document.data - }); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error adding HTML document:', error); - reject(error); - }; - - transaction.oncomplete = () => { - console.log('HTML document added successfully:', document.name); - resolve(); - }; - } catch (error) { - console.error('Error in addHTMLDocument transaction:', error); - reject(error); - } - }); - } - - async getHTMLDocument(id: string): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Fetching HTML document:', id); - const transaction = this.db!.transaction([HTML_STORE_NAME], 'readonly'); - const store = transaction.objectStore(HTML_STORE_NAME); - const request = store.get(id); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error fetching HTML document:', error); - reject(error); - }; - - request.onsuccess = () => { - console.log('HTML Document fetch result:', request.result ? 'found' : 'not found'); - resolve(request.result); - }; - } catch (error) { - console.error('Error in getHTMLDocument transaction:', error); - reject(error); - } - }); - } - - async getAllHTMLDocuments(): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Fetching all HTML documents'); - const transaction = this.db!.transaction([HTML_STORE_NAME], 'readonly'); - const store = transaction.objectStore(HTML_STORE_NAME); - const request = store.getAll(); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error fetching all HTML documents:', error); - reject(error); - }; - - request.onsuccess = () => { - console.log('Retrieved HTML documents count:', request.result?.length || 0); - resolve(request.result || []); - }; - } catch (error) { - console.error('Error in getAllHTMLDocuments transaction:', error); - reject(error); - } - }); - } - - async removeHTMLDocument(id: string): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Removing HTML document:', id); - const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite'); - const store = transaction.objectStore(HTML_STORE_NAME); - const request = store.delete(id); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error removing HTML document:', error); - reject(error); - }; - - transaction.oncomplete = () => { - console.log('HTML document removed successfully:', id); - resolve(); - }; - } catch (error) { - console.error('Error in removeHTMLDocument transaction:', error); - reject(error); - } - }); - } - - async clearHTMLDocuments(): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Clearing all HTML documents'); - const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite'); - const store = transaction.objectStore(HTML_STORE_NAME); - const request = store.clear(); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error clearing HTML documents:', error); - reject(error); - }; - - transaction.oncomplete = () => { - console.log('All HTML documents cleared successfully'); - resolve(); - }; - } catch (error) { - console.error('Error in clearHTMLDocuments transaction:', error); - reject(error); - } - }); - } - - // Add EPUB Document Methods - async addEPUBDocument(document: EPUBDocument): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - if (document.data.byteLength === 0) { - throw new Error('Cannot store empty ArrayBuffer'); - } - - console.log('Storing document:', { - name: document.name, - size: document.size, - actualSize: document.data.byteLength - }); - - const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite'); - const store = transaction.objectStore(EPUB_STORE_NAME); - - // Create a structured clone of the document to ensure proper storage - const request = store.put({ - ...document, - data: document.data.slice(0) // Create a copy of the ArrayBuffer - }); - - request.onerror = (event) => { - console.error('Error storing document:', (event.target as IDBRequest).error); - reject((event.target as IDBRequest).error); - }; - - transaction.oncomplete = () => { - console.log('Document stored successfully'); - resolve(); - }; - } catch (error) { - console.error('Error in addEPUBDocument:', error); - reject(error); - } - }); - } - - async getEPUBDocument(id: string): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readonly'); - const store = transaction.objectStore(EPUB_STORE_NAME); - const request = store.get(id); - - request.onerror = (event) => { - console.error('Error fetching document:', (event.target as IDBRequest).error); - reject((event.target as IDBRequest).error); - }; - - request.onsuccess = () => { - const doc = request.result; - if (doc) { - console.log('Retrieved document from DB:', { - name: doc.name, - size: doc.size, - actualSize: doc.data.byteLength - }); - } - resolve(doc); - }; - } catch (error) { - console.error('Error in getEPUBDocument:', error); - reject(error); - } - }); - } - - async getAllEPUBDocuments(): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readonly'); - const store = transaction.objectStore(EPUB_STORE_NAME); - const request = store.getAll(); - - request.onerror = (event) => { - reject((event.target as IDBRequest).error); - }; - - request.onsuccess = () => { - resolve(request.result || []); - }; - } catch (error) { - reject(error); - } - }); - } - - async removeEPUBDocument(id: string): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Removing EPUB document:', id); - const locationKey = `lastLocation_${id}`; - - // Create two transactions - one for document deletion, one for location cleanup - const docTransaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite'); - const configTransaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite'); - - const docStore = docTransaction.objectStore(EPUB_STORE_NAME); - const configStore = configTransaction.objectStore(CONFIG_STORE_NAME); - - // Remove the document - const docRequest = docStore.delete(id); - // Remove any saved location - const locationRequest = configStore.delete(locationKey); - - docRequest.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error removing EPUB document:', error); - reject(error); - }; - - locationRequest.onerror = (event) => { - console.warn('Error cleaning up location:', (event.target as IDBRequest).error); - // Don't reject here, as document deletion is the primary operation - }; - - // Wait for both transactions to complete - Promise.all([ - new Promise((res) => { docTransaction.oncomplete = () => res(); }), - new Promise((res) => { configTransaction.oncomplete = () => res(); }) - ]).then(() => { - console.log('EPUB document and location data removed successfully:', id); - resolve(); - }).catch(reject); - - } catch (error) { - console.error('Error in removeEPUBDocument transaction:', error); - reject(error); - } - }); - } - - // Config Methods - async setConfigItem(key: string, value: string): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Setting config item:', key); - const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite'); - const store = transaction.objectStore(CONFIG_STORE_NAME); - const request = store.put({ key, value }); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error setting config item:', error); - reject(error); - }; - - transaction.oncomplete = () => { - console.log('Config item set successfully:', key); - resolve(); - }; - } catch (error) { - console.error('Error in setConfigItem transaction:', error); - reject(error); - } - }); - } - - async getConfigItem(key: string): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Fetching config item:', key); - const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readonly'); - const store = transaction.objectStore(CONFIG_STORE_NAME); - const request = store.get(key); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error fetching config item:', error); - reject(error); - }; - - request.onsuccess = () => { - const result = request.result as Config | undefined; - console.log('Config item fetch result:', result ? 'found' : 'not found'); - resolve(result ? result.value : null); - }; - } catch (error) { - console.error('Error in getConfigItem transaction:', error); - reject(error); - } - }); - } - - async getAllConfig(): Promise> { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Fetching all config items'); - const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readonly'); - const store = transaction.objectStore(CONFIG_STORE_NAME); - const request = store.getAll(); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error fetching all config items:', error); - reject(error); - }; - - request.onsuccess = () => { - const result = request.result as Config[]; - const config: Record = {}; - result.forEach((item) => { - config[item.key] = item.value; - }); - console.log('Retrieved config items count:', result.length); - resolve(config); - }; - } catch (error) { - console.error('Error in getAllConfig transaction:', error); - reject(error); - } - }); - } - - async removeConfigItem(key: string): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Removing config item:', key); - const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite'); - const store = transaction.objectStore(CONFIG_STORE_NAME); - const request = store.delete(key); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error removing config item:', error); - reject(error); - }; - - transaction.oncomplete = () => { - console.log('Config item removed successfully:', key); - resolve(); - }; - } catch (error) { - console.error('Error in removeConfigItem transaction:', error); - reject(error); - } - }); - } - - async syncToServer(onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal): Promise<{ lastSync: number }> { - const pdfDocs = await this.getAllDocuments(); - const epubDocs = await this.getAllEPUBDocuments(); - - const documents = []; - const totalDocs = pdfDocs.length + epubDocs.length; - let processedDocs = 0; - - // Process PDF documents - convert ArrayBuffer to array for JSON serialization - for (const doc of pdfDocs) { - documents.push({ - ...doc, - type: 'pdf', - data: Array.from(new Uint8Array(doc.data)) - }); - processedDocs++; - if (onProgress) { - onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`); - } - } - - // Process EPUB documents - for (const doc of epubDocs) { - documents.push({ - ...doc, - type: 'epub', - data: Array.from(new Uint8Array(doc.data)) - }); - processedDocs++; - if (onProgress) { - onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`); - } - } - - if (onProgress) { - onProgress(50, 'Uploading to server...'); - } - - const response = await fetch('/api/documents', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ documents }), - signal - }); - - if (!response.ok) { - throw new Error('Failed to sync documents to server'); - } - - if (onProgress) { - onProgress(100, 'Upload complete!'); - } - - return { lastSync: Date.now() }; - } - - async loadFromServer(onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal): Promise<{ lastSync: number }> { - if (onProgress) { - onProgress(10, 'Starting download...'); - } - - const response = await fetch('/api/documents', { signal }); - if (!response.ok) { - throw new Error('Failed to fetch documents from server'); - } - - if (onProgress) { - onProgress(30, 'Download complete'); - } - - const { documents } = await response.json(); - - if (onProgress) { - onProgress(40, 'Parsing documents...'); - } - - // Process each document - for (let i = 0; i < documents.length; i++) { - const doc = documents[i]; - // Convert the numeric array back to ArrayBuffer - const uint8Array = new Uint8Array(doc.data); - const documentData = { - id: doc.id, - type: doc.type, - name: doc.name, - size: doc.size, - lastModified: doc.lastModified, - data: uint8Array.buffer - }; - - if (doc.type === 'pdf') { - await this.addDocument(documentData); - } else if (doc.type === 'epub') { - await this.addEPUBDocument(documentData); - } else { - console.warn(`Unknown document type: ${doc.type}`); - } - - if (onProgress) { - // Progress from 40% to 90% for document processing - onProgress(40 + ((i + 1) / documents.length) * 50, `Processing document ${i + 1}/${documents.length}...`); - } - } - - if (onProgress) { - onProgress(100, 'Load complete!'); - } - - return { lastSync: Date.now() }; - } - - async clearPDFDocuments(): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Clearing all PDF documents'); - const transaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite'); - const store = transaction.objectStore(PDF_STORE_NAME); - const request = store.clear(); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error clearing PDF documents:', error); - reject(error); - }; - - transaction.oncomplete = () => { - console.log('All PDF documents cleared successfully'); - resolve(); - }; - } catch (error) { - console.error('Error in clearPDFDocuments transaction:', error); - reject(error); - } - }); - } - - async clearEPUBDocuments(): Promise { - if (!this.db) { - await this.init(); - } - - return new Promise((resolve, reject) => { - try { - console.log('Clearing all EPUB documents'); - const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite'); - const store = transaction.objectStore(EPUB_STORE_NAME); - const request = store.clear(); - - request.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error('Error clearing EPUB documents:', error); - reject(error); - }; - - transaction.oncomplete = () => { - console.log('All EPUB documents cleared successfully'); - resolve(); - }; - } catch (error) { - console.error('Error in clearEPUBDocuments transaction:', error); - reject(error); - } - }); - } - - async saveDocumentListState(state: DocumentListState): Promise { - return this.setConfigItem('documentListState', JSON.stringify(state)); - } - - async getDocumentListState(): Promise { - const stateStr = await this.getConfigItem('documentListState'); - if (!stateStr) return null; - try { - return JSON.parse(stateStr); - } catch (error) { - console.error('Error parsing document list state:', error); - return null; - } - } -} - -// Make sure we export a singleton instance -const indexedDBServiceInstance = new IndexedDBService(); -export const indexedDBService = indexedDBServiceInstance; - -// Helper functions for the ConfigContext -export async function getItem(key: string): Promise { - return indexedDBService.getConfigItem(key); -} - -export async function setItem(key: string, value: string): Promise { - return indexedDBService.setConfigItem(key, value); -} - -export async function removeItem(key: string): Promise { - return indexedDBService.removeConfigItem(key); -} - -// Add these helper functions before the final export -export async function getLastDocumentLocation(docId: string): Promise { - const key = `lastLocation_${docId}`; - return indexedDBService.getConfigItem(key); -} - -export async function setLastDocumentLocation(docId: string, location: string): Promise { - const key = `lastLocation_${docId}`; - return indexedDBService.setConfigItem(key, location); -} - -export async function getDocumentListState(): Promise { - return indexedDBService.getDocumentListState(); -} - -export async function saveDocumentListState(state: DocumentListState): Promise { - return indexedDBService.saveDocumentListState(state); -} \ No newline at end of file