refactor(db): migrate to Dexie with reactive queries and simplify data layer
Replaces custom IndexedDB implementation with Dexie ORM, eliminating 850+ lines of boilerplate code and introducing reactive live queries across all document types. Transforms document management from imperative refresh patterns to automatic reactive updates using dexie-react-hooks. Simplifies TTS backend by removing concurrency semaphore while maintaining request de-duplication through in-flight tracking. Streamlines document hooks by removing manual state management and refresh methods. Updates package dependencies and type definitions to support new database architecture while maintaining full backward compatibility for existing documents and settings. BREAKING CHANGE: Document hooks no longer expose refresh() methods as updates are now reactive through live queries.
This commit is contained in:
parent
e3799e40aa
commit
5316596a1b
19 changed files with 607 additions and 1164 deletions
129
README.md
129
README.md
|
|
@ -7,28 +7,63 @@
|
|||
|
||||
[](../../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**
|
||||
<details>
|
||||
<summary>
|
||||
|
||||
### 🆕 What's New in v1.0.0
|
||||
|
||||
</summary>
|
||||
|
||||
- 🧠 **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.
|
||||
|
||||
</details>
|
||||
|
||||
## 🐳 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:**
|
||||
<details>
|
||||
<summary>
|
||||
|
||||
**Docker CPU**
|
||||
|
||||
</summary>
|
||||
|
||||
```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:**
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
|
||||
**Docker GPU**
|
||||
|
||||
</summary>
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name kokoro-tts \
|
||||
|
|
@ -113,23 +162,31 @@ docker run -d \
|
|||
ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.4
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
> **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/)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,36 +23,6 @@ const ttsAudioCache = new LRUCache<string, AudioBufferValue>({
|
|||
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<ArrayBuffer>;
|
||||
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);
|
||||
}
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -25,10 +25,6 @@ interface DocumentContextType {
|
|||
removeHTMLDocument: (id: string) => Promise<void>;
|
||||
isHTMLLoading: boolean;
|
||||
|
||||
refreshPDFs: () => Promise<void>;
|
||||
refreshEPUBs: () => Promise<void>;
|
||||
refreshHTML: () => Promise<void>;
|
||||
|
||||
clearPDFs: () => Promise<void>;
|
||||
clearEPUBs: () => Promise<void>;
|
||||
clearHTML: () => Promise<void>;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
try {
|
||||
const doc = await indexedDBService.getHTMLDocument(id);
|
||||
const doc = await getHtmlDocument(id);
|
||||
if (doc) {
|
||||
setCurrDocName(doc.name);
|
||||
setCurrDocData(doc.data);
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
try {
|
||||
const doc = await indexedDBService.getDocument(id);
|
||||
const doc = await getPdfDocument(id);
|
||||
if (doc) {
|
||||
setCurrDocName(doc.name);
|
||||
setCurrDocData(doc.data);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<EPUBDocument[]>([]);
|
||||
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<string> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDocument[]>([]);
|
||||
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<string> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PDFDocument[]>([]);
|
||||
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<string>} The ID of the added document
|
||||
*/
|
||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
413
src/utils/dexie.ts
Normal file
413
src/utils/dexie.ts
Normal file
|
|
@ -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<PDFDocument, 'id'>;
|
||||
[EPUB_TABLE]: EntityTable<EPUBDocument, 'id'>;
|
||||
[HTML_TABLE]: EntityTable<HTMLDocument, 'id'>;
|
||||
[CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>;
|
||||
};
|
||||
|
||||
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<void> | null = null;
|
||||
|
||||
export async function initDB(): Promise<void> {
|
||||
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<T>(operation: () => Promise<T>): Promise<T> {
|
||||
await initDB();
|
||||
return operation();
|
||||
}
|
||||
|
||||
// PDF document helpers
|
||||
|
||||
export async function addPdfDocument(document: PDFDocument): Promise<void> {
|
||||
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<PDFDocument | undefined> {
|
||||
return withDB(async () => {
|
||||
console.log('Fetching PDF document via Dexie:', id);
|
||||
return db[PDF_TABLE].get(id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllPdfDocuments(): Promise<PDFDocument[]> {
|
||||
return withDB(async () => {
|
||||
console.log('Fetching all PDF documents via Dexie');
|
||||
return db[PDF_TABLE].toArray();
|
||||
});
|
||||
}
|
||||
|
||||
export async function removePdfDocument(id: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<EPUBDocument | undefined> {
|
||||
return withDB(async () => {
|
||||
console.log('Fetching EPUB document via Dexie:', id);
|
||||
return db[EPUB_TABLE].get(id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllEpubDocuments(): Promise<EPUBDocument[]> {
|
||||
return withDB(async () => {
|
||||
console.log('Fetching all EPUB documents via Dexie');
|
||||
return db[EPUB_TABLE].toArray();
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeEpubDocument(id: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<HTMLDocument | undefined> {
|
||||
return withDB(async () => {
|
||||
console.log('Fetching HTML document via Dexie:', id);
|
||||
return db[HTML_TABLE].get(id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllHtmlDocuments(): Promise<HTMLDocument[]> {
|
||||
return withDB(async () => {
|
||||
console.log('Fetching all HTML documents via Dexie');
|
||||
return db[HTML_TABLE].toArray();
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeHtmlDocument(id: string): Promise<void> {
|
||||
await withDB(async () => {
|
||||
console.log('Removing HTML document via Dexie:', id);
|
||||
await db[HTML_TABLE].delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearHtmlDocuments(): Promise<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<Record<string, string>> {
|
||||
const rows = await withDB(() => db[CONFIG_TABLE].toArray());
|
||||
const config: Record<string, string> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
await setConfigItem('documentListState', JSON.stringify(state));
|
||||
}
|
||||
|
||||
export async function getDocumentListState(): Promise<DocumentListState | null> {
|
||||
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<string | null> {
|
||||
const key = `lastLocation_${docId}`;
|
||||
return getConfigItem(key);
|
||||
}
|
||||
|
||||
export async function setLastDocumentLocation(docId: string, location: string): Promise<void> {
|
||||
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() };
|
||||
}
|
||||
|
||||
|
|
@ -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<void> | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
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<void> {
|
||||
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<PDFDocument | undefined> {
|
||||
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<PDFDocument[]> {
|
||||
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<void> {
|
||||
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<void>((res) => { docTransaction.oncomplete = () => res(); }),
|
||||
new Promise<void>((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<void> {
|
||||
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<HTMLDocument | undefined> {
|
||||
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<HTMLDocument[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<EPUBDocument | undefined> {
|
||||
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<EPUBDocument[]> {
|
||||
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<void> {
|
||||
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<void>((res) => { docTransaction.oncomplete = () => res(); }),
|
||||
new Promise<void>((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<void> {
|
||||
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<string | null> {
|
||||
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<Record<string, string>> {
|
||||
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<string, string> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
return this.setConfigItem('documentListState', JSON.stringify(state));
|
||||
}
|
||||
|
||||
async getDocumentListState(): Promise<DocumentListState | null> {
|
||||
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<string | null> {
|
||||
return indexedDBService.getConfigItem(key);
|
||||
}
|
||||
|
||||
export async function setItem(key: string, value: string): Promise<void> {
|
||||
return indexedDBService.setConfigItem(key, value);
|
||||
}
|
||||
|
||||
export async function removeItem(key: string): Promise<void> {
|
||||
return indexedDBService.removeConfigItem(key);
|
||||
}
|
||||
|
||||
// Add these helper functions before the final export
|
||||
export async function getLastDocumentLocation(docId: string): Promise<string | null> {
|
||||
const key = `lastLocation_${docId}`;
|
||||
return indexedDBService.getConfigItem(key);
|
||||
}
|
||||
|
||||
export async function setLastDocumentLocation(docId: string, location: string): Promise<void> {
|
||||
const key = `lastLocation_${docId}`;
|
||||
return indexedDBService.setConfigItem(key, location);
|
||||
}
|
||||
|
||||
export async function getDocumentListState(): Promise<DocumentListState | null> {
|
||||
return indexedDBService.getDocumentListState();
|
||||
}
|
||||
|
||||
export async function saveDocumentListState(state: DocumentListState): Promise<void> {
|
||||
return indexedDBService.saveDocumentListState(state);
|
||||
}
|
||||
Loading…
Reference in a new issue