From 199b937a255017b0572649b013cfd48e615912af Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 19 Jan 2026 14:22:21 -0700 Subject: [PATCH 01/12] refactor: overhaul document storage and audiobook architecture - refactor(documents): implement stable SHA256-based document IDs - feat(library): add support for external document libraries - refactor(audiobook): consolidate server logic and migrate to V1 storage layout - feat(audiobook): add reset functionality and improve chapter management - fix(ui): enhance audiobook export modal with download/regenerate controls - test: add coverage for upload hashing and export reset flow --- README.md | 76 ++- playwright.config.ts | 15 +- src/app/api/audiobook/chapter/route.ts | 71 +-- src/app/api/audiobook/route.ts | 257 ++++++--- src/app/api/audiobook/status/route.ts | 83 +-- .../api/documents/library/content/route.ts | 104 ++++ src/app/api/documents/library/route.ts | 128 +++++ src/app/api/documents/route.ts | 219 +++++-- src/app/api/migrations/v1/route.ts | 135 +++++ src/app/epub/[id]/page.tsx | 31 +- src/app/html/[id]/page.tsx | 19 +- src/app/pdf/[id]/page.tsx | 30 +- src/components/AudiobookExportModal.tsx | 383 +++++++++---- src/components/DocumentSettings.tsx | 15 +- src/components/Footer.tsx | 1 + src/components/ProgressCard.tsx | 3 +- src/components/ProgressPopup.tsx | 4 +- src/components/SettingsModal.tsx | 61 +- src/components/doclist/DocumentPreview.tsx | 72 +-- src/components/player/SpeedControl.tsx | 47 +- src/components/player/VoicesControl.tsx | 132 +---- src/components/player/VoicesControlBase.tsx | 138 +++++ src/contexts/ConfigContext.tsx | 52 +- src/contexts/EPUBContext.tsx | 82 ++- src/contexts/PDFContext.tsx | 82 ++- src/hooks/epub/useEPUBDocuments.ts | 6 +- src/hooks/html/useHTMLDocuments.ts | 10 +- src/hooks/pdf/usePDFDocuments.ts | 6 +- src/lib/client.ts | 8 +- src/lib/dexie.ts | 366 +++++++++++- src/lib/pdf.ts | 48 +- src/lib/server/audiobook.ts | 215 +++++++ src/lib/server/docstore.ts | 538 ++++++++++++++++++ src/lib/server/library.ts | 48 ++ src/lib/sha256.ts | 158 +++++ src/types/client.ts | 11 + src/types/documents.ts | 5 + tests/export.spec.ts | 20 +- tests/upload.spec.ts | 39 ++ 39 files changed, 3095 insertions(+), 623 deletions(-) create mode 100644 src/app/api/documents/library/content/route.ts create mode 100644 src/app/api/documents/library/route.ts create mode 100644 src/app/api/migrations/v1/route.ts create mode 100644 src/components/player/VoicesControlBase.tsx create mode 100644 src/lib/server/audiobook.ts create mode 100644 src/lib/server/docstore.ts create mode 100644 src/lib/server/library.ts create mode 100644 src/lib/sha256.ts diff --git a/README.md b/README.md index 02dc60d..c2127a6 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,6 @@ OpenReader WebUI is an open source text to speech document reader web app built docker run --name openreader-webui \ --restart unless-stopped \ -p 3003:3003 \ - -v openreader_docstore:/app/docstore \ ghcr.io/richardr1126/openreader-webui:latest ``` @@ -54,15 +53,12 @@ OpenReader WebUI is an open source text to speech document reader web app built -e API_KEY=none \ -e API_BASE=http://host.docker.internal:8880/v1 \ -p 3003:3003 \ - -v openreader_docstore:/app/docstore \ ghcr.io/richardr1126/openreader-webui:latest ``` - > **Note:** Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`. - Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings. - > **Note:** The `openreader_docstore` volume is used to store server-side documents. You can mount a local directory instead. Or remove it if you don't need server-side documents. + > **Note:** Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`. ### 2. ⚙️ Configure the app settings in the UI: - Set the TTS Provider and Model in the Settings modal @@ -76,6 +72,60 @@ docker rm openreader-webui && \ docker pull ghcr.io/richardr1126/openreader-webui:latest ``` +### 📦 Volume mounts and Library import + +By default (no volume mounts), OpenReader will store its server-side files inside the container filesystem (which is lost if you remove the container). + +
+ + +**Persist server-side storage (`/app/docstore`)** + + + +Run the container with the volume mounted: +```bash +docker run --name openreader-webui \ + --restart unless-stopped \ + -p 3003:3003 \ + -v openreader_docstore:/app/docstore \ + ghcr.io/richardr1126/openreader-webui:latest +``` +This will create a Docker named volume `openreader_docstore` to persist all server-side files, including: + +- **Documents:** Stored under `/app/docstore/documents_v1` +- **Audiobook exports:** Stored under `/app/docstore/audiobooks_v1` + - Per-audiobook settings: `/app/docstore/audiobooks_v1/-audiobook/audiobook.meta.json` + - Chapters: `0001__.m4b` or `0001__<title>.mp3` (no per-chapter `.meta.json` files) +- **Settings** + +This ensures that your documents, exported audiobooks, and server-side settings are retained even if the container is removed or recreated. + +</details> + +<details open> +<summary> + +**Mount an external library folder (read-only recommended)** + +</summary> + +```bash +docker run --name openreader-webui \ + --restart unless-stopped \ + -p 3003:3003 \ + -v openreader_docstore:/app/docstore \ + -v /path/to/your/library:/app/docstore/library:ro \ + ghcr.io/richardr1126/openreader-webui:latest +``` +Seperate from the docstore volume, this will mount an external folder to `/app/docstore/library` (read-only recommended). This allows you to connect OpenReader to an existing library of documents. + +To import from the mounted library: **Settings → Documents → Server Library Import** + +> **Note:** Every file in the mounted volume is imported to the client browser's storage. Please ensure that the mounted library is not too large to avoid performance issues. + +</details> + ### 🗣️ 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](https://github.com/remsky/Kokoro-FastAPI).** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version). @@ -84,7 +134,7 @@ You can run the Kokoro TTS API server directly with Docker. **We are not respons <details> <summary> -**Docker CPU** +**Kokoro-FastAPI (CPU)** </summary> @@ -110,7 +160,7 @@ docker run -d \ <details> <summary> -**Docker GPU** +**Kokoro-FastAPI (GPU)** </summary> @@ -225,9 +275,9 @@ Contributions are welcome! Fork the repository and submit a pull request with yo This project would not be possible without standing on the shoulders of these giants: - [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) model -- [Orpheus-TTS](https://huggingface.co/collections/canopylabs/orpheus-tts-67d9ea3f6c05a941c06ad9d2) model - [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) -- [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI) +- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) +- [ffmpeg](https://ffmpeg.org) - [react-pdf](https://github.com/wojtekmaj/react-pdf) npm package - [react-reader](https://github.com/happyr/react-reader) npm package @@ -239,7 +289,8 @@ This project would not be possible without standing on the shoulders of these gi - **Framework:** Next.js (React) - **Containerization:** Docker -- **Storage:** Dexie + IndexedDB (in-browser local database) +- **Storage:** + - [Dexie.js](https://dexie.org/) IndexedDB wrapper for client-side storage - **PDF:** - [react-pdf](https://github.com/wojtekmaj/react-pdf) - [pdf.js](https://mozilla.github.io/pdf.js/) @@ -257,7 +308,10 @@ This project would not be possible without standing on the shoulders of these gi - [Deepinfra API](https://deepinfra.com) (Kokoro-82M, Orpheus-3B, Sesame-1B) - [Kokoro FastAPI TTS](https://github.com/remsky/Kokoro-FastAPI/tree/v0.0.5post1-stable) - [Orpheus FastAPI TTS](https://github.com/Lex-au/Orpheus-FastAPI) -- **NLP:** [compromise](https://github.com/spencermountain/compromise) NLP library for sentence splitting +- **NLP:** + - [compromise](https://github.com/spencermountain/compromise) NLP library for sentence splitting + - [cmpstr](https://github.com/remsky/cmpstr) String comparison library + - [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for TTS timestamps (word-by-word highlighting) ## License diff --git a/playwright.config.ts b/playwright.config.ts index 5d20f5f..7b20bed 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -36,17 +36,26 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + extraHTTPHeaders: { 'x-openreader-test-namespace': 'chromium' }, + }, }, { name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + use: { + ...devices['Desktop Firefox'], + extraHTTPHeaders: { 'x-openreader-test-namespace': 'firefox' }, + }, }, { name: 'webkit', - use: { ...devices['Desktop Safari'] }, + use: { + ...devices['Desktop Safari'], + extraHTTPHeaders: { 'x-openreader-test-namespace': 'webkit' }, + }, }, /* Test against mobile viewports. */ diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 5a43d47..1500efc 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -1,7 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; import { createReadStream, existsSync } from 'fs'; -import { readFile, unlink } from 'fs/promises'; +import { readdir, unlink } from 'fs/promises'; import { join } from 'path'; +import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { findStoredChapterByIndex } from '@/lib/server/audiobook'; + +function getAudiobooksRootDir(request: NextRequest): string { + const raw = request.headers.get('x-openreader-test-namespace')?.trim(); + if (!raw) return AUDIOBOOKS_V1_DIR; + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); + return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; +} export async function GET(request: NextRequest) { try { @@ -23,25 +32,21 @@ export async function GET(request: NextRequest) { ); } - const docstoreDir = join(process.cwd(), 'docstore'); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); - // Read metadata to get format - const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`); - if (!existsSync(metadataPath)) { + const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal); + if (!chapter || !existsSync(chapter.filePath)) { return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }); } - const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')); - const format = metadata.format || 'm4b'; - const chapterPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`); - - if (!existsSync(chapterPath)) { - return NextResponse.json({ error: 'Chapter file not found' }, { status: 404 }); - } - // Stream the chapter file - const stream = createReadStream(chapterPath); + const stream = createReadStream(chapter.filePath); const readableWebStream = new ReadableStream({ start(controller) { @@ -60,13 +65,13 @@ export async function GET(request: NextRequest) { } }); - const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; - const sanitizedTitle = metadata.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + const mimeType = chapter.format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; + const sanitizedTitle = chapter.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); return new NextResponse(readableWebStream, { headers: { 'Content-Type': mimeType, - 'Content-Disposition': `attachment; filename="${sanitizedTitle}.${format}"`, + 'Content-Disposition': `attachment; filename="${sanitizedTitle}.${chapter.format}"`, 'Cache-Control': 'no-cache', }, }); @@ -100,21 +105,19 @@ export async function DELETE(request: NextRequest) { ); } - const docstoreDir = join(process.cwd(), 'docstore'); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); - - // Read metadata to get format (if present) - const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`); - - // Delete the chapter audio file (try both formats just in case) - const chapterPathM4b = join(intermediateDir, `${chapterIndex}-chapter.m4b`); - const chapterPathMp3 = join(intermediateDir, `${chapterIndex}-chapter.mp3`); - if (existsSync(chapterPathM4b)) await unlink(chapterPathM4b).catch(() => {}); - if (existsSync(chapterPathMp3)) await unlink(chapterPathMp3).catch(() => {}); - - // Delete metadata if present - if (existsSync(metadataPath)) { - await unlink(metadataPath).catch(() => {}); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; + const files = await readdir(intermediateDir).catch(() => []); + for (const file of files) { + if (!file.startsWith(chapterPrefix)) continue; + if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue; + await unlink(join(intermediateDir, file)).catch(() => {}); } // Invalidate any combined "complete" files @@ -122,6 +125,8 @@ export async function DELETE(request: NextRequest) { const completeMp3 = join(intermediateDir, `complete.mp3`); if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => {}); if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => {}); + await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => {}); + await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => {}); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 17bf12a..5e30469 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -1,10 +1,20 @@ import { NextRequest, NextResponse } from 'next/server'; import { spawn } from 'child_process'; -import { writeFile, readFile, mkdir, unlink, readdir, rm } from 'fs/promises'; +import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/promises'; import { existsSync, createReadStream } from 'fs'; -import { join } from 'path'; +import { basename, join } from 'path'; import { randomUUID } from 'crypto'; +import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio } from '@/lib/server/audiobook'; import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; +import type { AudiobookGenerationSettings } from '@/types/client'; + +function getAudiobooksRootDir(request: NextRequest): string { + const raw = request.headers.get('x-openreader-test-namespace')?.trim(); + if (!raw) return AUDIOBOOKS_V1_DIR; + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); + return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; +} interface ConversionRequest { chapterTitle: string; @@ -12,6 +22,7 @@ interface ConversionRequest { bookId?: string; format?: TTSAudiobookFormat; chapterIndex?: number; + settings?: AudiobookGenerationSettings; } async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise<number> { @@ -119,38 +130,85 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> { }); } +function buildAtempoFilter(speed: number): string { + const clamped = Math.max(0.5, Math.min(speed, 3)); + // atempo supports 0.5..2.0 per filter; chain for >2.0 + if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`; + const second = clamped / 2; + return `atempo=2.0,atempo=${second.toFixed(3)}`; +} + export async function POST(request: NextRequest) { try { // Parse the request body const data: ConversionRequest = await request.json(); - const format = data.format || 'm4b'; + const requestedFormat = data.format || 'm4b'; - // Use docstore directory - const docstoreDir = join(process.cwd(), 'docstore'); - if (!existsSync(docstoreDir)) { - await mkdir(docstoreDir); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); } // Generate or use existing book ID const bookId = data.bookId || randomUUID(); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); // Create intermediate directory - if (!existsSync(intermediateDir)) { - await mkdir(intermediateDir); + await mkdir(intermediateDir, { recursive: true }); + + const metaPath = join(intermediateDir, 'audiobook.meta.json'); + const incomingSettings = data.settings; + let existingSettings: AudiobookGenerationSettings | null = null; + try { + existingSettings = JSON.parse(await readFile(metaPath, 'utf8')) as AudiobookGenerationSettings; + } catch { + existingSettings = null; } + if (existingSettings) { + if (incomingSettings) { + const mismatch = + existingSettings.ttsProvider !== incomingSettings.ttsProvider || + existingSettings.ttsModel !== incomingSettings.ttsModel || + existingSettings.voice !== incomingSettings.voice || + existingSettings.nativeSpeed !== incomingSettings.nativeSpeed || + existingSettings.postSpeed !== incomingSettings.postSpeed || + existingSettings.format !== incomingSettings.format; + if (mismatch) { + return NextResponse.json( + { error: 'Audiobook settings mismatch', settings: existingSettings }, + { status: 409 }, + ); + } + } + } else if (incomingSettings) { + await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2)); + } + + const existingChapters = await listStoredChapters(intermediateDir, request.signal); + const existingFormats = new Set(existingChapters.map((c) => c.format)); + if (existingFormats.size > 1) { + return NextResponse.json( + { error: 'Mixed chapter formats detected; reset the audiobook to continue' }, + { status: 400 }, + ); + } + + const format: TTSAudiobookFormat = + (existingFormats.values().next().value as TTSAudiobookFormat | undefined) ?? + existingSettings?.format ?? + incomingSettings?.format ?? + requestedFormat; + const postSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1; + // Use provided chapter index or find the next available index robustly (handles gaps) let chapterIndex: number; if (data.chapterIndex !== undefined) { chapterIndex = data.chapterIndex; } else { - const files = await readdir(intermediateDir); - const indices = files - .map(f => f.match(/^(\d+)-chapter\.(m4b|mp3)$/)) - .filter((m): m is RegExpMatchArray => Boolean(m)) - .map(m => parseInt(m[1], 10)) - .sort((a, b) => a - b); + const indices = existingChapters.map((c) => c.index); // Find smallest non-negative integer not present let next = 0; for (const idx of indices) { @@ -165,43 +223,69 @@ export async function POST(request: NextRequest) { // Write input file (MP3 from TTS) const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`); - const chapterOutputPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`); - const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`); + const chapterOutputTempPath = join(intermediateDir, `${chapterIndex}-chapter.tmp.${format}`); + const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle); // Write the chapter audio to a temp file await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer))); - + + // We intentionally do not delete the existing chapter file up-front. This avoids a long + // window where the chapter is "missing" while ffmpeg is running (which can lead to + // partial/stale "complete.*" downloads). We clean up duplicates and invalidate the + // combined output only after the new chapter is written successfully. + if (format === 'mp3') { // For MP3, re-encode to ensure proper headers and consistent format await runFFmpeg([ '-y', // Overwrite output file without asking '-i', inputPath, + ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), '-c:a', 'libmp3lame', '-b:a', '64k', - '-metadata', `title=${data.chapterTitle}`, - chapterOutputPath + '-metadata', `title=${titleTag}`, + chapterOutputTempPath ], request.signal); } else { // Convert MP3 to M4B container with proper encoding and metadata await runFFmpeg([ '-y', // Overwrite output file without asking '-i', inputPath, + ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), '-c:a', 'aac', '-b:a', '64k', - '-metadata', `title=${data.chapterTitle}`, + '-metadata', `title=${titleTag}`, '-f', 'mp4', - chapterOutputPath + chapterOutputTempPath ], request.signal); } - // Get the duration and save metadata - const duration = await getAudioDuration(chapterOutputPath, request.signal); - await writeFile(metadataPath, JSON.stringify({ - title: data.chapterTitle, - duration, - index: chapterIndex, - format - })); + const probe = await ffprobeAudio(chapterOutputTempPath, request.signal); + const duration = probe.durationSec ?? (await getAudioDuration(chapterOutputTempPath, request.signal)); + + const finalChapterPath = join(intermediateDir, encodeChapterFileName(chapterIndex, data.chapterTitle, format)); + await unlink(finalChapterPath).catch(() => {}); + await rename(chapterOutputTempPath, finalChapterPath); + + // Remove any existing chapter files for this index (e.g., if the title changed and the + // filename changed) and invalidate the combined output now that the chapter is updated. + const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; + const finalChapterName = basename(finalChapterPath); + const existingFiles = await readdir(intermediateDir).catch(() => []); + for (const file of existingFiles) { + if (!file.startsWith(chapterPrefix)) continue; + if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue; + if (file === finalChapterName) continue; + await unlink(join(intermediateDir, file)).catch(() => {}); + } + await unlink(join(intermediateDir, 'complete.mp3')).catch(() => {}); + await unlink(join(intermediateDir, 'complete.m4b')).catch(() => {}); + await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => {}); + await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => {}); + + // Ensure meta exists after first successful chapter. + if (!existingSettings && incomingSettings) { + await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2)).catch(() => {}); + } // Clean up input file await unlink(inputPath).catch(console.error); @@ -238,69 +322,104 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } - const docstoreDir = join(process.cwd(), 'docstore'); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); if (!existsSync(intermediateDir)) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - // Read all chapter metadata - const files = await readdir(intermediateDir); - const metaFiles = files.filter(f => f.endsWith('.meta.json')); - const chapters: { title: string; duration: number; index: number; format: string }[] = []; - - for (const metaFile of metaFiles) { - const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8')); - chapters.push(meta); - } + const stored = await listStoredChapters(intermediateDir, request.signal); + const chapters = stored.map((chapter) => ({ + title: chapter.title, + duration: chapter.durationSec ?? 0, + index: chapter.index, + format: chapter.format, + filePath: chapter.filePath, + })); if (chapters.length === 0) { return NextResponse.json({ error: 'No chapters found' }, { status: 404 }); } + const chapterFormats = new Set(chapters.map((chapter) => chapter.format)); + if (chapterFormats.size > 1) { + return NextResponse.json( + { error: 'Mixed chapter formats detected; reset the audiobook to continue' }, + { status: 400 }, + ); + } + // Sort chapters by index chapters.sort((a, b) => a.index - b.index); - // Determine output format from existing chapter metadata to avoid mismatches - const chapterFormat = (chapters[0]?.format === 'mp3' || chapters[0]?.format === 'm4b') ? chapters[0].format : 'm4b'; - if (requestedFormat && requestedFormat !== chapterFormat) { - console.warn(`Requested format ${requestedFormat} differs from chapter format ${chapterFormat}. Using ${chapterFormat}.`); - } - const format = chapterFormat; + const format: TTSAudiobookFormat = requestedFormat ?? (chapters[0]?.format as TTSAudiobookFormat) ?? 'm4b'; const outputPath = join(intermediateDir, `complete.${format}`); + const manifestPath = join(intermediateDir, `complete.${format}.manifest.json`); const metadataPath = join(intermediateDir, 'metadata.txt'); const listPath = join(intermediateDir, 'list.txt'); - // Check if combined file already exists + const signature = chapters.map((chapter) => ({ + index: chapter.index, + fileName: basename(chapter.filePath), + })); + if (existsSync(outputPath)) { - // Stream the existing file - return streamFile(outputPath, format); + let cached: typeof signature | null = null; + try { + cached = JSON.parse(await readFile(manifestPath, 'utf8')) as typeof signature; + } catch { + cached = null; + } + + if (cached && JSON.stringify(cached) === JSON.stringify(signature)) { + return streamFile(outputPath, format); + } + + await unlink(outputPath).catch(() => {}); + await unlink(manifestPath).catch(() => {}); + } + + // Ensure we have chapter durations for chapter markers / ordering. + for (const chapter of chapters) { + if (chapter.duration && chapter.duration > 0) continue; + try { + const probe = await ffprobeAudio(chapter.filePath, request.signal); + if (probe.durationSec && probe.durationSec > 0) { + chapter.duration = probe.durationSec; + continue; + } + } catch {} + + try { + chapter.duration = await getAudioDuration(chapter.filePath, request.signal); + } catch { + chapter.duration = 0; + } } // Create chapter metadata file for M4B const metadata: string[] = []; let currentTime = 0; - - chapters.forEach((chapter) => { + + for (const chapter of chapters) { const startMs = Math.floor(currentTime * 1000); currentTime += chapter.duration; const endMs = Math.floor(currentTime * 1000); - metadata.push( - `[CHAPTER]`, - `TIMEBASE=1/1000`, - `START=${startMs}`, - `END=${endMs}`, - `title=${chapter.title}` - ); - }); + metadata.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${startMs}`, `END=${endMs}`, `title=${chapter.title}`); + } await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n')); // Create list file for concat await writeFile( listPath, - chapters.map(c => `file '${join(intermediateDir, `${c.index}-chapter.${format}`)}'`).join('\n') + chapters.map(c => `file '${c.filePath}'`).join('\n') ); if (format === 'mp3') { @@ -322,7 +441,8 @@ export async function GET(request: NextRequest) { '-i', listPath, '-i', metadataPath, '-map_metadata', '1', - '-c:a', 'copy', + '-c:a', 'aac', + '-b:a', '64k', '-f', 'mp4', outputPath ], request.signal); @@ -334,6 +454,8 @@ export async function GET(request: NextRequest) { unlink(listPath).catch(console.error) ]); + await writeFile(manifestPath, JSON.stringify(signature, null, 2)).catch(() => {}); + // Stream the file back to the client return streamFile(outputPath, format); @@ -390,8 +512,13 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } - const docstoreDir = join(process.cwd(), 'docstore'); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); // If directory doesn't exist, consider it already reset if (!existsSync(intermediateDir)) { diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 44e8fd6..f279372 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -1,8 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; -import { readdir, readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; -import type { TTSAudiobookFormat } from '@/types/tts'; +import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { listStoredChapters } from '@/lib/server/audiobook'; +import type { AudiobookGenerationSettings } from '@/types/client'; +import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts'; +import { readFile } from 'fs/promises'; + +function getAudiobooksRootDir(request: NextRequest): string { + const raw = request.headers.get('x-openreader-test-namespace')?.trim(); + if (!raw) return AUDIOBOOKS_V1_DIR; + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); + return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; +} export async function GET(request: NextRequest) { try { @@ -11,54 +21,49 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } - const docstoreDir = join(process.cwd(), 'docstore'); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); if (!existsSync(intermediateDir)) { - return NextResponse.json({ chapters: [], exists: false }); + return NextResponse.json({ + chapters: [], + exists: false, + hasComplete: false, + bookId: null, + settings: null, + }); } - // Read all chapter metadata - const files = await readdir(intermediateDir); - const metaFiles = files.filter(f => f.endsWith('.meta.json')); - const chapters: Array<{ - index: number; - title: string; - duration?: number; - status: 'completed' | 'error'; - bookId: string; - format?: TTSAudiobookFormat; - }> = []; - - for (const metaFile of metaFiles) { - try { - const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8')); - chapters.push({ - index: meta.index, - title: meta.title, - duration: meta.duration, - status: 'completed', - bookId, - format: meta.format || 'm4b' - }); - } catch (error) { - console.error(`Error reading metadata file ${metaFile}:`, error); - } + const stored = await listStoredChapters(intermediateDir, request.signal); + const chapters: TTSAudiobookChapter[] = stored.map((chapter) => ({ + index: chapter.index, + title: chapter.title, + duration: chapter.durationSec, + status: 'completed', + bookId, + format: chapter.format as TTSAudiobookFormat, + })); + + let settings: AudiobookGenerationSettings | null = null; + try { + settings = JSON.parse(await readFile(join(intermediateDir, 'audiobook.meta.json'), 'utf8')) as AudiobookGenerationSettings; + } catch { + settings = null; } - // Sort chapters by index - chapters.sort((a, b) => a.index - b.index); - - // Check if complete audiobook exists (either format) - const format = chapters[0]?.format || 'm4b'; - const completePath = join(intermediateDir, `complete.${format}`); - const hasComplete = existsSync(completePath); + const hasComplete = existsSync(join(intermediateDir, 'complete.mp3')) || existsSync(join(intermediateDir, 'complete.m4b')); return NextResponse.json({ chapters, exists: true, hasComplete, - bookId + bookId, + settings, }); } catch (error) { diff --git a/src/app/api/documents/library/content/route.ts b/src/app/api/documents/library/content/route.ts new file mode 100644 index 0000000..721779e --- /dev/null +++ b/src/app/api/documents/library/content/route.ts @@ -0,0 +1,104 @@ +import { readFile, stat } from 'fs/promises'; +import path from 'path'; +import { NextRequest, NextResponse } from 'next/server'; +import { contentTypeForName, decodeLibraryId, isPathWithinRoot, parseLibraryRoots } from '@/lib/server/library'; + +export const dynamic = 'force-dynamic'; + +const HEADER_CONTROL_CHARS_REGEX = /[\u0000-\u001F\u007F]/g; + +function stripHeaderControlChars(value: string): string { + return value.replace(HEADER_CONTROL_CHARS_REGEX, ''); +} + +function toAsciiFilenameFallback(filename: string): string { + const stripped = stripHeaderControlChars(filename); + const withoutQuotes = stripped.replace(/["\\]/g, ''); + const asciiOnly = withoutQuotes.replace(/[^\x20-\x7E]/g, '_').replace(/[\/\\]/g, '_').trim(); + return asciiOnly || 'download'; +} + +function encodeRFC5987ValueChars(value: string): string { + const stripped = stripHeaderControlChars(value); + const bytes = new TextEncoder().encode(stripped); + let out = ''; + + for (const byte of bytes) { + const isAlphaNumeric = + (byte >= 0x30 && byte <= 0x39) || + (byte >= 0x41 && byte <= 0x5a) || + (byte >= 0x61 && byte <= 0x7a); + + const isAttrChar = + isAlphaNumeric || + byte === 0x21 || + byte === 0x23 || + byte === 0x24 || + byte === 0x26 || + byte === 0x2b || + byte === 0x2d || + byte === 0x2e || + byte === 0x5e || + byte === 0x5f || + byte === 0x60 || + byte === 0x7c || + byte === 0x7e; + + out += isAttrChar ? String.fromCharCode(byte) : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; + } + + return out || 'download'; +} + +function contentDispositionAttachment(filename: string): string { + const fallback = toAsciiFilenameFallback(filename); + const encoded = encodeRFC5987ValueChars(filename); + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const id = url.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + } + + const decoded = decodeLibraryId(id); + if (!decoded) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + const roots = parseLibraryRoots(); + const root = roots[decoded.rootIndex]; + if (!root) { + return NextResponse.json({ error: 'Invalid library root' }, { status: 400 }); + } + + const resolvedRoot = path.resolve(root); + const resolvedFile = path.resolve(resolvedRoot, decoded.relativePath); + if (!isPathWithinRoot(resolvedRoot, resolvedFile)) { + return NextResponse.json({ error: 'Invalid path' }, { status: 400 }); + } + + let fileStat: Awaited<ReturnType<typeof stat>>; + try { + fileStat = await stat(resolvedFile); + } catch { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (!fileStat.isFile()) { + return NextResponse.json({ error: 'Not a file' }, { status: 400 }); + } + + const content = await readFile(resolvedFile); + const fileName = path.basename(decoded.relativePath); + + return new NextResponse(content, { + headers: { + 'Content-Type': contentTypeForName(fileName), + 'Content-Disposition': contentDispositionAttachment(fileName), + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/src/app/api/documents/library/route.ts b/src/app/api/documents/library/route.ts new file mode 100644 index 0000000..a4cb0d0 --- /dev/null +++ b/src/app/api/documents/library/route.ts @@ -0,0 +1,128 @@ +import type { Dirent } from 'fs'; +import { readdir, stat } from 'fs/promises'; +import path from 'path'; +import { NextRequest, NextResponse } from 'next/server'; +import { parseLibraryRoots } from '@/lib/server/library'; +import type { DocumentType } from '@/types/documents'; + +export const dynamic = 'force-dynamic'; + +type LibraryDocument = { + id: string; + name: string; + size: number; + lastModified: number; + type: DocumentType; +}; + +const SUPPORTED_EXTENSIONS = new Set([ + '.pdf', + '.epub', + '.html', + '.htm', + '.txt', + '.md', + '.mdown', + '.markdown', +]); + +const IGNORE_DIR_NAMES = new Set(['.git', 'node_modules', 'model', 'tmp', 'documents', 'documents_v1', 'audiobooks_v1']); + +function toPosixPath(filePath: string): string { + return filePath.split(path.sep).join('/'); +} + +function libraryDocumentTypeFromName(name: string): DocumentType { + const ext = path.extname(name).toLowerCase(); + if (ext === '.pdf') return 'pdf'; + if (ext === '.epub') return 'epub'; + return 'html'; +} + +let cache: + | { + cacheKey: string; + cachedAt: number; + documents: LibraryDocument[]; + } + | undefined; + +async function scanLibraryRoot(root: string, rootIndex: number, limit: number): Promise<LibraryDocument[]> { + const results: LibraryDocument[] = []; + const resolvedRoot = path.resolve(root); + + async function walk(currentDir: string): Promise<void> { + if (results.length >= limit) return; + + let entries: Dirent[]; + try { + entries = await readdir(currentDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (results.length >= limit) return; + + if (entry.isDirectory()) { + if (entry.name.startsWith('.')) continue; + if (IGNORE_DIR_NAMES.has(entry.name)) continue; + if (entry.name.endsWith('-audiobook')) continue; + await walk(path.join(currentDir, entry.name)); + continue; + } + + if (!entry.isFile()) continue; + + const ext = path.extname(entry.name).toLowerCase(); + if (!SUPPORTED_EXTENSIONS.has(ext)) continue; + + const fullPath = path.join(currentDir, entry.name); + let fileStat: Awaited<ReturnType<typeof stat>>; + try { + fileStat = await stat(fullPath); + } catch { + continue; + } + + const relativePath = toPosixPath(path.relative(resolvedRoot, fullPath)); + const id = Buffer.from(`${rootIndex}:${relativePath}`, 'utf8').toString('base64url'); + + results.push({ + id, + name: relativePath, + size: fileStat.size, + lastModified: fileStat.mtimeMs, + type: libraryDocumentTypeFromName(relativePath), + }); + } + } + + await walk(resolvedRoot); + return results; +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const refresh = url.searchParams.get('refresh') === '1'; + const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit') ?? '5000'), 10000)); + + const roots = parseLibraryRoots(); + const cacheKey = `${roots.join('|')}::${limit}`; + + if (!refresh && cache && cache.cacheKey === cacheKey && Date.now() - cache.cachedAt < 30_000) { + return NextResponse.json({ documents: cache.documents }); + } + + const documents: LibraryDocument[] = []; + for (let i = 0; i < roots.length; i++) { + const rootDocs = await scanLibraryRoot(roots[i], i, Math.max(0, limit - documents.length)); + documents.push(...rootDocs); + if (documents.length >= limit) break; + } + + documents.sort((a, b) => a.name.localeCompare(b.name)); + + cache = { cacheKey, cachedAt: Date.now(), documents }; + return NextResponse.json({ documents }); +} diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index f926e2f..c822058 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -1,82 +1,162 @@ -import { writeFile, readFile, readdir, mkdir, unlink } from 'fs/promises'; +import { createHash } from 'crypto'; +import { readdir, readFile, stat, unlink, utimes, writeFile } from 'fs/promises'; import { NextRequest, NextResponse } from 'next/server'; import path from 'path'; -import type { BaseDocument, SyncedDocument } from '@/types/documents'; +import { DOCUMENTS_V1_DIR, isDocumentsV1Ready } from '@/lib/server/docstore'; +import type { BaseDocument, DocumentType, SyncedDocument } from '@/types/documents'; -const DOCS_DIR = path.join(process.cwd(), 'docstore'); +const SYNC_DIR = DOCUMENTS_V1_DIR; + +async function trySetFileMtime(filePath: string, lastModifiedMs: number): Promise<void> { + if (!Number.isFinite(lastModifiedMs)) return; + const mtime = new Date(lastModifiedMs); + if (Number.isNaN(mtime.getTime())) return; -// Ensure documents directory exists -async function ensureDocsDir() { try { - await mkdir(DOCS_DIR, { recursive: true }); + await utimes(filePath, mtime, mtime); } catch (error) { - console.error('Error creating documents directory:', error); + console.warn('Failed to set document mtime:', filePath, error); } } +function toDocumentTypeFromName(name: string): DocumentType { + const ext = path.extname(name).toLowerCase(); + if (ext === '.pdf') return 'pdf'; + if (ext === '.epub') return 'epub'; + if (ext === '.docx') return 'docx'; + return 'html'; +} + +function parseSyncedFileName(fileName: string): { id: string; name: string } | null { + const match = /^([a-f0-9]{64})__(.+)$/i.exec(fileName); + if (!match) return null; + try { + return { id: match[1].toLowerCase(), name: decodeURIComponent(match[2]) }; + } catch { + return null; + } +} + +async function loadSyncedDocuments(includeData: boolean): Promise<(BaseDocument | SyncedDocument)[]> { + const results: (BaseDocument | SyncedDocument)[] = []; + let files: string[] = []; + + try { + files = await readdir(SYNC_DIR); + } catch { + return results; + } + + for (const file of files) { + const parsed = parseSyncedFileName(file); + if (!parsed) continue; + + const filePath = path.join(SYNC_DIR, file); + let fileStat: Awaited<ReturnType<typeof stat>>; + try { + fileStat = await stat(filePath); + } catch { + continue; + } + + if (!fileStat.isFile()) continue; + + const type = toDocumentTypeFromName(parsed.name); + const metadata: BaseDocument = { + id: parsed.id, + name: parsed.name, + size: fileStat.size, + lastModified: fileStat.mtimeMs, + type, + }; + + if (!includeData) { + results.push(metadata); + continue; + } + + const content = await readFile(filePath); + results.push({ + ...metadata, + data: Array.from(new Uint8Array(content)), + }); + } + + return results; +} + export async function POST(req: NextRequest) { try { - await ensureDocsDir(); + if (!(await isDocumentsV1Ready())) { + return NextResponse.json( + { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } const data = await req.json(); const documents = data.documents as SyncedDocument[]; - - // Save document metadata and content - for (const doc of documents) { - const docPath = path.join(DOCS_DIR, `${doc.id}.json`); - const contentPath = path.join(DOCS_DIR, `${doc.id}.${doc.type}`); - - // Save metadata (excluding binary data) - const metadata = { - id: doc.id, - name: doc.name, - size: doc.size, - lastModified: doc.lastModified, - type: doc.type - }; - - await writeFile(docPath, JSON.stringify(metadata)); - // Save content as raw binary file with proper handling for both PDF and EPUB - const content = Buffer.from(new Uint8Array(doc.data)); - await writeFile(contentPath, content); + let existingFiles: string[] = []; + try { + existingFiles = await readdir(SYNC_DIR); + } catch { + existingFiles = []; } - return NextResponse.json({ success: true }); + const existingById = new Map<string, string>(); + for (const file of existingFiles) { + const parsed = parseSyncedFileName(file); + if (!parsed) continue; + if (!existingById.has(parsed.id)) { + existingById.set(parsed.id, file); + } + } + + const stored: Array<{ oldId: string; id: string; name: string }> = []; + + for (const doc of documents) { + const content = Buffer.from(new Uint8Array(doc.data)); + const id = createHash('sha256').update(content).digest('hex'); + + const baseName = path.basename(doc.name || `${id}.${doc.type}`); + const safeName = baseName.replaceAll('\u0000', '').slice(0, 240) || `${id}.${doc.type}`; + + const existingFile = existingById.get(id); + const targetFileName = existingFile ?? `${id}__${encodeURIComponent(safeName)}`; + const targetPath = path.join(SYNC_DIR, targetFileName); + + if (!existingFile) { + await writeFile(targetPath, content); + existingById.set(id, targetFileName); + } + + await trySetFileMtime(targetPath, doc.lastModified); + + stored.push({ oldId: doc.id, id, name: safeName }); + } + + return NextResponse.json({ success: true, stored }); } catch (error) { console.error('Error saving documents:', error); return NextResponse.json({ error: 'Failed to save documents' }, { status: 500 }); } } -export async function GET() { +export async function GET(req: NextRequest) { try { - await ensureDocsDir(); - const documents: SyncedDocument[] = []; - - const files = await readdir(DOCS_DIR); - const jsonFiles = files.filter(file => file.endsWith('.json')); - - for (const file of jsonFiles) { - const docPath = path.join(DOCS_DIR, file); - - try { - 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); - - // Ensure consistent array format for both PDF and EPUB - const uint8Array = new Uint8Array(content); - - documents.push({ - ...metadata, - data: Array.from(uint8Array) - }); - } catch (error) { - console.error(`Error processing file ${file}:`, error); - continue; - } + if (!(await isDocumentsV1Ready())) { + return NextResponse.json( + { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); } + const url = new URL(req.url); + const format = url.searchParams.get('format') ?? 'sync'; + const includeData = format !== 'metadata'; + + const documents = await loadSyncedDocuments(includeData); + return NextResponse.json({ documents }); } catch (error) { console.error('Error loading documents:', error); @@ -86,12 +166,31 @@ export async function GET() { export async function DELETE() { try { - await ensureDocsDir(); - const files = await readdir(DOCS_DIR); - - for (const file of files) { - const filePath = path.join(DOCS_DIR, file); - await unlink(filePath); + if (!(await isDocumentsV1Ready())) { + return NextResponse.json( + { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + + // Delete synced docs (new format) safely without touching unrelated docstore data. + let syncFiles: string[] = []; + try { + syncFiles = await readdir(SYNC_DIR); + } catch { + syncFiles = []; + } + + for (const file of syncFiles) { + const filePath = path.join(SYNC_DIR, file); + try { + const st = await stat(filePath); + if (st.isFile()) { + await unlink(filePath); + } + } catch { + continue; + } } return NextResponse.json({ success: true }); diff --git a/src/app/api/migrations/v1/route.ts b/src/app/api/migrations/v1/route.ts new file mode 100644 index 0000000..0ea512a --- /dev/null +++ b/src/app/api/migrations/v1/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { existsSync } from 'fs'; +import { mkdir, readdir, rename, rm } from 'fs/promises'; +import { join } from 'path'; +import { + AUDIOBOOKS_V1_DIR, + ensureAudiobooksV1Ready, + ensureDocumentsV1Ready, + isAudiobooksV1Ready, + isDocumentsV1Ready, +} from '@/lib/server/docstore'; + +type Mapping = { oldId: string; id: string }; + +function isSafeId(value: string): boolean { + return /^[a-zA-Z0-9._-]{1,128}$/.test(value); +} + +async function mergeDirectoryContents(sourceDir: string, targetDir: string): Promise<{ moved: number; skipped: number }> { + let moved = 0; + let skipped = 0; + + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(sourceDir, { withFileTypes: true }); + } catch { + return { moved, skipped }; + } + + for (const entry of entries) { + const sourcePath = join(sourceDir, entry.name); + const targetPath = join(targetDir, entry.name); + + if (entry.isDirectory()) { + await mkdir(targetPath, { recursive: true }); + const nested = await mergeDirectoryContents(sourcePath, targetPath); + moved += nested.moved; + skipped += nested.skipped; + + try { + const remaining = await readdir(sourcePath); + if (remaining.length === 0) { + await rm(sourcePath); + } + } catch {} + continue; + } + + if (!entry.isFile()) continue; + + if (existsSync(targetPath)) { + skipped++; + continue; + } + + try { + await rename(sourcePath, targetPath); + moved++; + } catch { + skipped++; + } + } + + return { moved, skipped }; +} + +async function rekeyAudiobooksV1(mappings: Mapping[]): Promise<{ renamed: number; merged: number; skipped: number }> { + let renamed = 0; + let merged = 0; + let skipped = 0; + + for (const mapping of mappings) { + if (mapping.oldId === mapping.id) continue; + const sourceDir = join(AUDIOBOOKS_V1_DIR, `${mapping.oldId}-audiobook`); + if (!existsSync(sourceDir)) continue; + + const targetDir = join(AUDIOBOOKS_V1_DIR, `${mapping.id}-audiobook`); + if (!existsSync(targetDir)) { + try { + await rename(sourceDir, targetDir); + renamed++; + continue; + } catch { + // Fall through to merge. + } + } + + await mkdir(targetDir, { recursive: true }); + const res = await mergeDirectoryContents(sourceDir, targetDir); + if (res.moved > 0) merged++; + skipped += res.skipped; + + try { + const remaining = await readdir(sourceDir); + if (remaining.length === 0) { + await rm(sourceDir); + } + } catch {} + } + + return { renamed, merged, skipped }; +} + +export async function POST(request: NextRequest) { + try { + const raw = (await request.json().catch(() => null)) as { mappings?: Mapping[] } | null; + const mappings = (raw?.mappings ?? []).filter( + (m): m is Mapping => Boolean(m && typeof m.oldId === 'string' && typeof m.id === 'string'), + ); + + for (const mapping of mappings) { + if (!isSafeId(mapping.oldId) || !isSafeId(mapping.id)) { + return NextResponse.json({ error: 'Invalid document id mapping' }, { status: 400 }); + } + } + + await ensureDocumentsV1Ready(); + await ensureAudiobooksV1Ready(); + const rekey = await rekeyAudiobooksV1(mappings); + + const documentsReady = await isDocumentsV1Ready(); + const audiobooksReady = await isAudiobooksV1Ready(); + + return NextResponse.json({ + success: true, + documentsReady, + audiobooksReady, + rekey, + }); + } catch (error) { + console.error('Error running v1 migrations:', error); + return NextResponse.json({ error: 'Failed to run v1 migrations' }, { status: 500 }); + } +} + diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index 325b96c..8cccb88 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; import { useEPUB } from '@/contexts/EPUBContext'; @@ -14,12 +14,15 @@ import TTSPlayer from '@/components/player/TTSPlayer'; import { ZoomControl } from '@/components/ZoomControl'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { DownloadIcon } from '@/components/icons/Icons'; -import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; +import type { TTSAudiobookChapter } from '@/types/tts'; +import type { AudiobookGenerationSettings } from '@/types/client'; +import { resolveDocumentId } from '@/lib/dexie'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; export default function EPUBPage() { const { id } = useParams(); + const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB(); const { stop } = useTTS(); const [error, setError] = useState<string | null>(null); @@ -33,20 +36,28 @@ export default function EPUBPage() { const loadDocument = useCallback(async () => { console.log('Loading new epub (from page.tsx)'); stop(); // Reset TTS when loading new document - + let didRedirect = false; try { if (!id) { setError('Document not found'); return; } - await setCurrentDocument(id as string); + const resolved = await resolveDocumentId(id as string); + if (resolved !== (id as string)) { + didRedirect = true; + router.replace(`/epub/${resolved}`); + return; + } + await setCurrentDocument(resolved); } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); } finally { - setIsLoading(false); + if (!didRedirect) { + setIsLoading(false); + } } - }, [id, setCurrentDocument, stop]); + }, [id, router, setCurrentDocument, stop]); useEffect(() => { if (!isLoading) return; @@ -88,18 +99,18 @@ export default function EPUBPage() { onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, - format: TTSAudiobookFormat + settings: AudiobookGenerationSettings ) => { - return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format); + return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); }, [createEPUBAudioBook, id]); const handleRegenerateChapter = useCallback(async ( chapterIndex: number, bookId: string, - format: TTSAudiobookFormat, + settings: AudiobookGenerationSettings, signal: AbortSignal ) => { - return regenerateEPUBChapter(chapterIndex, bookId, format, signal); + return regenerateEPUBChapter(chapterIndex, bookId, settings.format, signal, settings); }, [regenerateEPUBChapter]); if (error) { diff --git a/src/app/html/[id]/page.tsx b/src/app/html/[id]/page.tsx index 8127fc0..a350b0f 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/html/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; import { useHTML } from '@/contexts/HTMLContext'; @@ -12,9 +12,11 @@ import { Header } from '@/components/Header'; import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; import { ZoomControl } from '@/components/ZoomControl'; +import { resolveDocumentId } from '@/lib/dexie'; export default function HTMLPage() { const { id } = useParams(); + const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc } = useHTML(); const { stop } = useTTS(); const [error, setError] = useState<string | null>(null); @@ -28,19 +30,28 @@ export default function HTMLPage() { if (!isLoading) return; console.log('Loading new HTML document (from page.tsx)'); stop(); + let didRedirect = false; try { if (!id) { setError('Document not found'); return; } - await setCurrentDocument(id as string); + const resolved = await resolveDocumentId(id as string); + if (resolved !== (id as string)) { + didRedirect = true; + router.replace(`/html/${resolved}`); + return; + } + await setCurrentDocument(resolved); } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); } finally { - setIsLoading(false); + if (!didRedirect) { + setIsLoading(false); + } } - }, [isLoading, id, setCurrentDocument, stop]); + }, [isLoading, id, router, setCurrentDocument, stop]); useEffect(() => { loadDocument(); diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 276dc52..8af59a4 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -2,7 +2,7 @@ import dynamic from 'next/dynamic'; import { usePDF } from '@/contexts/PDFContext'; -import { useParams } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; @@ -12,8 +12,10 @@ import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons'; import { Header } from '@/components/Header'; import { ZoomControl } from '@/components/ZoomControl'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; -import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; +import type { TTSAudiobookChapter } from '@/types/tts'; +import type { AudiobookGenerationSettings } from '@/types/client'; import TTSPlayer from '@/components/player/TTSPlayer'; +import { resolveDocumentId } from '@/lib/dexie'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -28,6 +30,7 @@ const PDFViewer = dynamic( export default function PDFViewerPage() { const { id } = useParams(); + const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF(); const { stop } = useTTS(); const [error, setError] = useState<string | null>(null); @@ -41,19 +44,28 @@ export default function PDFViewerPage() { if (!isLoading) return; // Prevent calls when not loading new doc console.log('Loading new document (from page.tsx)'); stop(); // Reset TTS when loading new document + let didRedirect = false; try { if (!id) { setError('Document not found'); return; } - setCurrentDocument(id as string); + const resolved = await resolveDocumentId(id as string); + if (resolved !== (id as string)) { + didRedirect = true; + router.replace(`/pdf/${resolved}`); + return; + } + setCurrentDocument(resolved); } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); } finally { - setIsLoading(false); + if (!didRedirect) { + setIsLoading(false); + } } - }, [isLoading, id, setCurrentDocument, stop]); + }, [isLoading, id, router, setCurrentDocument, stop]); useEffect(() => { loadDocument(); @@ -82,18 +94,18 @@ export default function PDFViewerPage() { onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, - format: TTSAudiobookFormat + settings: AudiobookGenerationSettings ) => { - return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format); + return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); }, [createPDFAudioBook, id]); const handleRegenerateChapter = useCallback(async ( chapterIndex: number, bookId: string, - format: TTSAudiobookFormat, + settings: AudiobookGenerationSettings, signal: AbortSignal ) => { - return regeneratePDFChapter(chapterIndex, bookId, format, signal); + return regeneratePDFChapter(chapterIndex, bookId, settings.format, signal, settings); }, [regeneratePDFChapter]); if (error) { diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index e0cb4ed..f71cb40 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Fragment, useState, useRef, useCallback, useEffect } from 'react'; +import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { Dialog, DialogPanel, Transition, TransitionChild, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { ProgressPopup } from '@/components/ProgressPopup'; @@ -9,6 +9,8 @@ import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIco import { ConfirmDialog } from '@/components/ConfirmDialog'; import { LoadingSpinner } from '@/components/Spinner'; import { useConfig } from '@/contexts/ConfigContext'; +import { useTTS } from '@/contexts/TTSContext'; +import { VoicesControlBase } from '@/components/player/VoicesControlBase'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; import { getAudiobookStatus, @@ -17,6 +19,7 @@ import { downloadAudiobookChapter, downloadAudiobook } from '@/lib/client'; +import type { AudiobookGenerationSettings } from '@/types/client'; interface AudiobookExportModalProps { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; @@ -26,12 +29,12 @@ interface AudiobookExportModalProps { onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, - format: TTSAudiobookFormat + settings: AudiobookGenerationSettings ) => Promise<string>; // Returns bookId onRegenerateChapter?: ( chapterIndex: number, bookId: string, - format: TTSAudiobookFormat, + settings: AudiobookGenerationSettings, signal: AbortSignal ) => Promise<TTSAudiobookChapter>; } @@ -44,7 +47,8 @@ export function AudiobookExportModal({ onGenerateAudiobook, onRegenerateChapter }: AudiobookExportModalProps) { - const { isLoading, isDBReady } = useConfig(); + const { isLoading, isDBReady, ttsProvider, ttsModel, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig(); + const { availableVoices } = useTTS(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const [isGenerating, setIsGenerating] = useState(false); const [chapters, setChapters] = useState<TTSAudiobookChapter[]>([]); @@ -54,6 +58,10 @@ export function AudiobookExportModal({ const [isRefreshingChapters, setIsRefreshingChapters] = useState(false); const [currentChapter, setCurrentChapter] = useState<string>(''); const [format, setFormat] = useState<TTSAudiobookFormat>('m4b'); + const [audiobookVoice, setAudiobookVoice] = useState<string>(configVoice || ''); + const [nativeSpeed, setNativeSpeed] = useState<number>(voiceSpeed); + const [postSpeed, setPostSpeed] = useState<number>(audioPlayerSpeed); + const [savedSettings, setSavedSettings] = useState<AudiobookGenerationSettings | null>(null); const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null); const abortControllerRef = useRef<AbortController | null>(null); const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null); @@ -61,6 +69,35 @@ export function AudiobookExportModal({ const [errorMessage, setErrorMessage] = useState<string | null>(null); const [showRegenerateHint, setShowRegenerateHint] = useState(false); + const formatSpeed = useCallback((speed: number) => { + return Number.isInteger(speed) ? speed.toString() : speed.toFixed(1); + }, []); + + const hasExistingAudiobook = Boolean(bookId) || chapters.length > 0; + const isLegacyAudiobookMissingSettings = hasExistingAudiobook && savedSettings === null; + + useEffect(() => { + if (savedSettings) return; + if (audiobookVoice) return; + if (availableVoices.length > 0) { + setAudiobookVoice(availableVoices[0] || ''); + } + }, [savedSettings, audiobookVoice, availableVoices]); + + const effectiveSettings: AudiobookGenerationSettings | null = useMemo(() => { + if (savedSettings) return savedSettings; + const nextVoice = audiobookVoice || configVoice || availableVoices[0] || ''; + if (!nextVoice) return null; + return { + ttsProvider, + ttsModel, + voice: nextVoice, + nativeSpeed, + postSpeed, + format, + }; + }, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, nativeSpeed, postSpeed, format]); + const fetchExistingChapters = useCallback(async (soft: boolean = false) => { if (soft) { setIsRefreshingChapters(true); @@ -69,15 +106,22 @@ export function AudiobookExportModal({ } try { const data = await getAudiobookStatus(documentId); - if (data.exists && data.chapters.length > 0) { - setChapters(data.chapters); + if (data.exists) { + setChapters(data.chapters || []); setBookId(data.bookId); - // Set format from existing chapters - this ensures the format matches what was actually generated if (data.chapters[0]?.format) { const detectedFormat = data.chapters[0].format as TTSAudiobookFormat; setFormat(detectedFormat); } - // If we have a complete audiobook, we're done + if (data.settings) { + setSavedSettings(data.settings); + setAudiobookVoice(data.settings.voice); + setNativeSpeed(data.settings.nativeSpeed); + setPostSpeed(data.settings.postSpeed); + setFormat(data.settings.format); + } else { + setSavedSettings(null); + } if (data.hasComplete) { setProgress(100); } @@ -85,6 +129,7 @@ export function AudiobookExportModal({ // If nothing exists, clear chapters/bookId to reflect current state setChapters([]); setBookId(null); + setSavedSettings(null); } } catch (error) { console.error('Error fetching existing chapters:', error); @@ -116,6 +161,10 @@ export function AudiobookExportModal({ }, []); const handleStartGeneration = useCallback(async () => { + if (!effectiveSettings) { + setErrorMessage('No voice selected; please choose a voice before generating.'); + return; + } setIsGenerating(true); setProgress(0); setCurrentChapter(''); @@ -131,7 +180,7 @@ export function AudiobookExportModal({ (progress) => setProgress(progress), abortControllerRef.current.signal, handleChapterComplete, - format + effectiveSettings ); setBookId(generatedBookId); } catch (error) { @@ -141,7 +190,7 @@ export function AudiobookExportModal({ console.log('Audiobook generation cancelled gracefully'); } else { // Show error to user for actual errors - setErrorMessage('Failed to generate audiobook. Please try again.'); + setErrorMessage(error instanceof Error ? error.message : 'Failed to generate audiobook. Please try again.'); } } finally { setIsGenerating(false); @@ -152,7 +201,7 @@ export function AudiobookExportModal({ await fetchExistingChapters(true); } } - }, [onGenerateAudiobook, handleChapterComplete, setProgress, bookId, format, documentId, fetchExistingChapters]); + }, [onGenerateAudiobook, handleChapterComplete, setProgress, bookId, documentId, fetchExistingChapters, effectiveSettings]); const handleCancel = useCallback(() => { if (abortControllerRef.current) { @@ -181,6 +230,10 @@ export function AudiobookExportModal({ const handleRegenerateChapter = useCallback(async (chapter: TTSAudiobookChapter) => { if (!onRegenerateChapter || !bookId) return; + if (!effectiveSettings) { + setErrorMessage('No voice selected; please choose a voice before generating.'); + return; + } if (!showRegenerateHint) { setShowRegenerateHint(true); @@ -208,7 +261,7 @@ export function AudiobookExportModal({ const regeneratedChapter = await onRegenerateChapter( chapter.index, bookId, - format, + effectiveSettings, abortControllerRef.current.signal ); @@ -224,7 +277,7 @@ export function AudiobookExportModal({ if (error instanceof Error && error.message.includes('cancelled')) { console.log('Chapter regeneration cancelled'); } else { - setErrorMessage('Failed to regenerate chapter. Please try again.'); + setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate chapter. Please try again.'); // Mark as error setChapters(prev => prev.map(c => c.index === chapter.index @@ -240,7 +293,7 @@ export function AudiobookExportModal({ // Refresh chapters to get updated data (soft refresh list only) await fetchExistingChapters(true); } - }, [onRegenerateChapter, bookId, format, setProgress, fetchExistingChapters, showRegenerateHint]); + }, [onRegenerateChapter, bookId, setProgress, fetchExistingChapters, showRegenerateHint, effectiveSettings]); const performDeleteChapter = useCallback(async () => { if (!bookId || !pendingDeleteChapter) return; @@ -358,6 +411,8 @@ export function AudiobookExportModal({ const hasAnyChapters = chapters.length > 0; const showResumeButton = !isGenerating && !regeneratingChapter && hasAnyChapters; const showResetButton = !isGenerating && !regeneratingChapter && hasAnyChapters; + const settingsLocked = savedSettings !== null; + const canGenerate = effectiveSettings !== null; // Do not render until storage/config is initialized if (isLoading || !isDBReady) { @@ -410,101 +465,203 @@ export function AudiobookExportModal({ <LoadingSpinner /> </div> ) : ( - <> - <div className="space-y-4"> - <div className="flex justify-between items-center gap-3"> - <h3 className="text-lg font-medium text-foreground">Export Audiobook</h3> - {!isGenerating && ( - <div className="flex items-center gap-2"> - {chapters.length === 0 && ( - <Listbox - value={format} - onChange={(newFormat) => setFormat(newFormat)} - disabled={chapters.length > 0} - > - <div className="relative"> - <ListboxButton className="relative cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent min-w-[100px]"> - <span className="block truncate text-sm font-medium">{format.toUpperCase()}</span> - <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> - <ChevronUpDownIcon className="h-5 w-5 text-muted" /> - </span> - </ListboxButton> - <Transition - as={Fragment} - leave="transition ease-in duration-100" - leaveFrom="opacity-100" - leaveTo="opacity-0" - > - <ListboxOptions className="absolute right-0 mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10"> - <ListboxOption - value="m4b" - className={({ active }) => - `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' - }` - } - > - {({ selected }) => ( - <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> - M4B - </span> - )} - </ListboxOption> - <ListboxOption - value="mp3" - className={({ active }) => - `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' - }` - } - > - {({ selected }) => ( - <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> - MP3 - </span> - )} - </ListboxOption> - </ListboxOptions> - </Transition> - </div> - </Listbox> - )} - {chapters.length === 0 && ( - <Button - onClick={handleStartGeneration} - className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm - font-medium text-background hover:bg-secondary-accent focus:outline-none - focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 - transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" - > - Start Generation - </Button> - )} - {showResumeButton && ( - <Button - onClick={handleStartGeneration} - className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm - font-medium text-background hover:bg-secondary-accent focus:outline-none - focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 - transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" - > - Resume - </Button> - )} - {showResetButton && ( - <Button - onClick={() => setShowResetConfirm(true)} - disabled={isGenerating} - className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm - font-medium text-background hover:bg-red-500/90 focus:outline-none - focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2 - transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" - title="Delete all generated chapters/pages for this document" - > - Reset - </Button> - )} - </div> - )} - </div> + <> + <div className="space-y-4"> + <div className="flex justify-between items-start gap-3"> + <h3 className="text-lg font-medium text-foreground">Export Audiobook</h3> + </div> + {!isGenerating && ( + <div className="flex justify-center"> + <div className="w-full rounded-xl border border-offbase bg-background p-4"> + <div className="flex items-start justify-between gap-3"> + <div> + <h4 className="text-sm font-medium text-foreground">Generation settings</h4> + <p className="text-xs text-muted mt-0.5"> + These settings are saved per audiobook for consistent resumes across devices. + </p> + </div> + {settingsLocked && ( + <span className="inline-flex items-center rounded-full bg-offbase px-2 py-0.5 text-xs text-muted"> + Locked + </span> + )} + </div> + + {isLegacyAudiobookMissingSettings && ( + <div className="mt-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-xs text-foreground"> + <div className="font-medium">Saved generation settings not found</div> + <div className="mt-1 text-muted"> + This audiobook was likely created before v1 metadata was introduced, so OpenReader can’t know + which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with + v1 metadata (so settings are saved for resumes across devices). + </div> + </div> + )} + + {settingsLocked && savedSettings ? ( + <div className="mt-3 text-sm"> + <div className="text-muted"> + Voice: <span className="text-foreground">{savedSettings.voice}</span> + </div> + <div className="text-muted"> + Native speed: <span className="text-foreground">{formatSpeed(savedSettings.nativeSpeed)}x</span> + </div> + <div className="text-muted"> + Post speed: <span className="text-foreground">{formatSpeed(savedSettings.postSpeed)}x</span> + </div> + <div className="text-muted"> + Format: <span className="text-foreground">{savedSettings.format.toUpperCase()}</span> + </div> + <p className="text-xs text-muted mt-2"> + Reset the audiobook to change generation settings. + </p> + </div> + ) : ( + <div className="mt-3 space-y-4"> + <div className="space-y-1"> + <div className="text-xs font-medium text-foreground">Voice</div> + <VoicesControlBase + availableVoices={availableVoices} + voice={audiobookVoice} + onChangeVoice={setAudiobookVoice} + ttsProvider={ttsProvider} + ttsModel={ttsModel} + /> + </div> + + <div className="space-y-1"> + <div className="text-xs font-medium text-foreground">Output format</div> + {chapters.length === 0 ? ( + <Listbox + value={format} + onChange={(newFormat) => setFormat(newFormat)} + disabled={chapters.length > 0 || settingsLocked} + > + <div className="relative inline-block"> + <ListboxButton className="relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent min-w-[120px]"> + <span className="block truncate text-sm font-medium">{format.toUpperCase()}</span> + <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> + <ChevronUpDownIcon className="h-5 w-5 text-muted" /> + </span> + </ListboxButton> + <Transition + as={Fragment} + leave="transition ease-in duration-100" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <ListboxOptions className="absolute left-0 mt-1 max-h-60 w-full overflow-auto rounded-md bg-base py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10"> + <ListboxOption + value="m4b" + className={({ active }) => + `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' + }` + } + > + {({ selected }) => ( + <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> + M4B + </span> + )} + </ListboxOption> + <ListboxOption + value="mp3" + className={({ active }) => + `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' + }` + } + > + {({ selected }) => ( + <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> + MP3 + </span> + )} + </ListboxOption> + </ListboxOptions> + </Transition> + </div> + </Listbox> + ) : ( + <div className="text-sm text-foreground">{format.toUpperCase()}</div> + )} + </div> + + <div className="space-y-1"> + <div className="flex items-center"> + <div className="text-xs font-medium text-foreground mr-1">Native model speed</div> + <div className="text-xs text-muted">• {formatSpeed(nativeSpeed)}x</div> + </div> + <input + type="range" + min="0.5" + max="3" + step="0.1" + value={nativeSpeed} + onChange={(e) => setNativeSpeed(parseFloat(e.target.value))} + className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent" + /> + </div> + + <div className="space-y-1"> + <div className="flex items-center"> + <div className="text-xs font-medium text-foreground mr-1">Post-generation speed</div> + <div className="text-xs text-muted">• {formatSpeed(postSpeed)}x</div> + </div> + <input + type="range" + min="0.5" + max="3" + step="0.1" + value={postSpeed} + onChange={(e) => setPostSpeed(parseFloat(e.target.value))} + className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent" + /> + </div> + </div> + )} + + <div className="mt-4 space-y-2"> + {chapters.length === 0 && ( + <Button + onClick={handleStartGeneration} + disabled={!canGenerate} + className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm + font-medium text-background hover:bg-secondary-accent focus:outline-none + focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 + transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100" + > + Start Generation + </Button> + )} + {showResumeButton && ( + <Button + onClick={handleStartGeneration} + disabled={!canGenerate} + className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm + font-medium text-background hover:bg-secondary-accent focus:outline-none + focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 + transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100" + > + Resume + </Button> + )} + {showResetButton && ( + <Button + onClick={() => setShowResetConfirm(true)} + disabled={isGenerating} + className="w-full justify-center rounded-lg bg-red-500 px-3 py-2 text-sm + font-medium text-background hover:bg-red-500/90 focus:outline-none + focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2 + transform transition-transform duration-200 ease-in-out hover:scale-[1.01]" + title="Delete all generated chapters/pages for this document" + > + Reset + </Button> + )} + </div> + </div> + </div> + )} {showRegenerateHint && ( <div className="flex items-start justify-between bg-offbase border border-offbase rounded-md px-3 py-2 text-xs sm:text-sm"> <p className="text-xs sm:text-sm text-foreground"> @@ -681,11 +838,11 @@ export function AudiobookExportModal({ )} {chapters.length === 0 && !isGenerating && !isLoadingExisting && ( - <div className="text-center py-8"> + <div className="text-center"> <p className="text-sm text-muted"> - Generation will use current TTS playback options. - <br /> - Individual chapters will appear here as they are generated. + Audiobook settings are fixed after generation. Chapters will appear here as they are ready. + <br></br> + You can close this dialog while the audiobook is being generated. But returning to the home screen will cancel the generation. </p> </div> )} diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx index 515d300..3434417 100644 --- a/src/components/DocumentSettings.tsx +++ b/src/components/DocumentSettings.tsx @@ -8,7 +8,8 @@ import { useEPUB } from '@/contexts/EPUBContext'; import { usePDF } from '@/contexts/PDFContext'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { useParams } from 'next/navigation'; -import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; +import type { TTSAudiobookChapter } from '@/types/tts'; +import type { AudiobookGenerationSettings } from '@/types/client'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -82,25 +83,25 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, - format: TTSAudiobookFormat + settings: AudiobookGenerationSettings ) => { if (epub) { - return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format); + return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); } else { - return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format); + return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); } }, [epub, createEPUBAudioBook, createPDFAudioBook, id]); const handleRegenerateChapter = useCallback(async ( chapterIndex: number, bookId: string, - format: TTSAudiobookFormat, + settings: AudiobookGenerationSettings, signal: AbortSignal ) => { if (epub) { - return regenerateEPUBChapter(chapterIndex, bookId, format, signal); + return regenerateEPUBChapter(chapterIndex, bookId, settings.format, signal, settings); } else { - return regeneratePDFChapter(chapterIndex, bookId, format, signal); + return regeneratePDFChapter(chapterIndex, bookId, settings.format, signal, settings); } }, [epub, regenerateEPUBChapter, regeneratePDFChapter]); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index f319e2e..904d843 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -91,6 +91,7 @@ export function Footer() { -e API_BASE=http://kokoro-tts:8880/v1 \\ -p 3003:3003 \\ -v openreader_docstore:/app/docstore \\ +-v /path/to/your/library:/app/docstore/library:ro \\ ghcr.io/richardr1126/openreader-webui:latest` } </CodeBlock> diff --git a/src/components/ProgressCard.tsx b/src/components/ProgressCard.tsx index 4f330d2..8bab973 100644 --- a/src/components/ProgressCard.tsx +++ b/src/components/ProgressCard.tsx @@ -2,7 +2,7 @@ interface ProgressCardProps { progress: number; estimatedTimeRemaining?: string; onCancel: (e?: React.MouseEvent) => void; - operationType?: 'sync' | 'load' | 'audiobook'; + operationType?: 'sync' | 'load' | 'library' | 'audiobook'; cancelText?: string; currentChapter?: string; completedChapters?: number; @@ -22,6 +22,7 @@ export function ProgressCard({ const getOperationLabel = () => { if (operationType === 'sync') return 'Saving to Server'; if (operationType === 'load') return 'Loading from Server'; + if (operationType === 'library') return 'Importing Library'; if (operationType === 'audiobook') return 'Generating Audiobook'; return null; }; diff --git a/src/components/ProgressPopup.tsx b/src/components/ProgressPopup.tsx index 7f37269..4e0c7db 100644 --- a/src/components/ProgressPopup.tsx +++ b/src/components/ProgressPopup.tsx @@ -8,7 +8,7 @@ interface ProgressPopupProps { estimatedTimeRemaining?: string; onCancel: () => void; statusMessage?: string; - operationType?: 'sync' | 'load' | 'audiobook'; + operationType?: 'sync' | 'load' | 'library' | 'audiobook'; cancelText?: string; onClick?: () => void; currentChapter?: string; @@ -65,4 +65,4 @@ export function ProgressPopup({ </div> </Transition> ); -} \ No newline at end of file +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index ea5a770..cbbba06 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -22,7 +22,7 @@ import { import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; -import { syncDocumentsToServer, loadDocumentsFromServer, getFirstVisit, setFirstVisit } from '@/lib/dexie'; +import { syncDocumentsToServer, loadDocumentsFromServer, importDocumentsFromLibrary, getFirstVisit, setFirstVisit } from '@/lib/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; @@ -51,9 +51,10 @@ export function SettingsModal() { const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions); const [isSyncing, setIsSyncing] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [isImportingLibrary, setIsImportingLibrary] = useState(false); const [showProgress, setShowProgress] = useState(false); const [statusMessage, setStatusMessage] = useState(''); - const [operationType, setOperationType] = useState<'sync' | 'load'>('sync'); + const [operationType, setOperationType] = useState<'sync' | 'load' | 'library'>('sync'); const [abortController, setAbortController] = useState<AbortController | null>(null); const selectedTheme = themes.find(t => t.id === theme) || themes[0]; const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false); @@ -214,6 +215,38 @@ export function SettingsModal() { } }; + const handleImportLibrary = async () => { + const controller = new AbortController(); + setAbortController(controller); + + try { + setIsImportingLibrary(true); + setShowProgress(true); + setProgress(0); + setOperationType('library'); + setStatusMessage('Scanning server library...'); + await importDocumentsFromLibrary((progress, status) => { + if (controller.signal.aborted) return; + setProgress(progress); + if (status) setStatusMessage(status); + }, controller.signal); + } catch (error) { + if (controller.signal.aborted) { + console.log('Library import cancelled'); + setStatusMessage('Operation cancelled'); + } else { + console.error('Library import failed:', error); + setStatusMessage('Library import failed. Please try again.'); + } + } finally { + setIsImportingLibrary(false); + setShowProgress(false); + setProgress(0); + setStatusMessage(''); + setAbortController(null); + } + }; + const handleClearLocal = async () => { await clearPDFs(); await clearEPUBs(); @@ -598,12 +631,29 @@ export function SettingsModal() { </TabPanel> <TabPanel className="space-y-4"> + <div className="space-y-1"> + <label className="block text-sm font-medium text-foreground">Server Library Import</label> + <div className="flex gap-2"> + <Button + onClick={handleImportLibrary} + disabled={isSyncing || isLoading || isImportingLibrary} + className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm + font-medium text-foreground hover:bg-offbase focus:outline-none + focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 + transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent + disabled:opacity-50" + > + {isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'} + </Button> + </div> + </div> + {isDev && <div className="space-y-1"> <label className="block text-sm font-medium text-foreground">Server Document Sync</label> <div className="flex gap-2"> <Button onClick={handleLoad} - disabled={isSyncing || isLoading} + disabled={isSyncing || isLoading || isImportingLibrary} className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 @@ -614,7 +664,7 @@ export function SettingsModal() { </Button> <Button onClick={handleSync} - disabled={isSyncing || isLoading} + disabled={isSyncing || isLoading || isImportingLibrary} className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 @@ -631,6 +681,7 @@ export function SettingsModal() { <div className="flex gap-2"> <Button onClick={() => setShowClearLocalConfirm(true)} + disabled={isSyncing || isLoading || isImportingLibrary} className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm font-medium text-background hover:bg-red-500/90 focus:outline-none focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2 @@ -640,6 +691,7 @@ export function SettingsModal() { </Button> {isDev && <Button onClick={() => setShowClearServerConfirm(true)} + disabled={isSyncing || isLoading || isImportingLibrary} className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm font-medium text-background hover:bg-red-500/90 focus:outline-none focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2 @@ -691,6 +743,7 @@ export function SettingsModal() { setProgress(0); setIsSyncing(false); setIsLoading(false); + setIsImportingLibrary(false); setStatusMessage(''); setOperationType('sync'); setAbortController(null); diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index c6691d1..5bc8eed 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -1,7 +1,7 @@ import { DocumentListDocument } from '@/types/documents'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { db } from '@/lib/dexie'; +import { getEpubDocument, getHtmlDocument, getPdfDocument } from '@/lib/dexie'; import { extractEpubCoverToDataUrl, extractRawTextSnippet, @@ -75,44 +75,44 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { let cancelled = false; - const run = async () => { - setIsGenerating(true); - try { - const targetWidth = 240; + const run = async () => { + setIsGenerating(true); + try { + const targetWidth = 240; - if (doc.type === 'pdf') { - const pdfDoc = await db['pdf-documents'].get(doc.id); - if (!pdfDoc?.data) return; - const dataUrl = await renderPdfFirstPageToDataUrl(pdfDoc.data, targetWidth); - if (cancelled) return; - imagePreviewCache.set(previewKey, dataUrl); - setImagePreview(dataUrl); - setTextPreview(null); - return; - } + if (doc.type === 'pdf') { + const pdfDoc = await getPdfDocument(doc.id); + if (!pdfDoc?.data) return; + const dataUrl = await renderPdfFirstPageToDataUrl(pdfDoc.data, targetWidth); + if (cancelled) return; + imagePreviewCache.set(previewKey, dataUrl); + setImagePreview(dataUrl); + setTextPreview(null); + return; + } - if (doc.type === 'epub') { - const epubDoc = await db['epub-documents'].get(doc.id); - if (!epubDoc?.data) return; - const cover = await extractEpubCoverToDataUrl(epubDoc.data, targetWidth); - if (cancelled) return; - if (cover) { - imagePreviewCache.set(previewKey, cover); - setImagePreview(cover); - setTextPreview(null); - } - return; - } + if (doc.type === 'epub') { + const epubDoc = await getEpubDocument(doc.id); + if (!epubDoc?.data) return; + const cover = await extractEpubCoverToDataUrl(epubDoc.data, targetWidth); + if (cancelled) return; + if (cover) { + imagePreviewCache.set(previewKey, cover); + setImagePreview(cover); + setTextPreview(null); + } + return; + } - if (doc.type === 'html') { - const htmlDoc = await db['html-documents'].get(doc.id); - if (cancelled) return; - const snippet = extractRawTextSnippet(htmlDoc?.data ?? ''); - textPreviewCache.set(previewKey, snippet); - setTextPreview(snippet); - setImagePreview(null); - return; - } + if (doc.type === 'html') { + const htmlDoc = await getHtmlDocument(doc.id); + if (cancelled) return; + const snippet = extractRawTextSnippet(htmlDoc?.data ?? ''); + textPreviewCache.set(previewKey, snippet); + setTextPreview(snippet); + setImagePreview(null); + return; + } } catch { // fall back to icon } finally { diff --git a/src/components/player/SpeedControl.tsx b/src/components/player/SpeedControl.tsx index 0463977..eafe41a 100644 --- a/src/components/player/SpeedControl.tsx +++ b/src/components/player/SpeedControl.tsx @@ -3,7 +3,7 @@ import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons'; import { useConfig } from '@/contexts/ConfigContext'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; export const SpeedControl = ({ setSpeedAndRestart, @@ -13,10 +13,10 @@ export const SpeedControl = ({ setAudioPlayerSpeedAndRestart: (speed: number) => void; }) => { const { voiceSpeed, audioPlayerSpeed } = useConfig(); + const [localVoiceSpeed, setLocalVoiceSpeed] = useState(voiceSpeed); const [localAudioSpeed, setLocalAudioSpeed] = useState(audioPlayerSpeed); - // Sync local speeds with global state useEffect(() => { setLocalVoiceSpeed(voiceSpeed); }, [voiceSpeed]); @@ -25,32 +25,31 @@ export const SpeedControl = ({ setLocalAudioSpeed(audioPlayerSpeed); }, [audioPlayerSpeed]); - // Handler for voice speed slider change (updates local state only) const handleVoiceSpeedChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { setLocalVoiceSpeed(parseFloat(event.target.value)); }, []); - // Handler for audio player speed slider change (updates local state only) const handleAudioSpeedChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { setLocalAudioSpeed(parseFloat(event.target.value)); }, []); - // Handler for voice speed slider release const handleVoiceSpeedChangeComplete = useCallback(() => { if (localVoiceSpeed !== voiceSpeed) { setSpeedAndRestart(localVoiceSpeed); } }, [localVoiceSpeed, voiceSpeed, setSpeedAndRestart]); - // Handler for audio player speed slider release const handleAudioSpeedChangeComplete = useCallback(() => { if (localAudioSpeed !== audioPlayerSpeed) { setAudioPlayerSpeedAndRestart(localAudioSpeed); } }, [localAudioSpeed, audioPlayerSpeed, setAudioPlayerSpeedAndRestart]); - // Display the currently active speed (prioritize audio player speed if different from 1.0) - const displaySpeed = localAudioSpeed !== 1.0 ? localAudioSpeed : localVoiceSpeed; + const displaySpeed = useMemo(() => (localAudioSpeed !== 1.0 ? localAudioSpeed : localVoiceSpeed), [localAudioSpeed, localVoiceSpeed]); + + const min = 0.5; + const max = 3; + const step = 0.1; return ( <Popover className="relative"> @@ -61,19 +60,20 @@ export const SpeedControl = ({ </PopoverButton> <PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase"> <div className="flex flex-col space-y-4"> - {/* Native Model Speed */} <div className="flex flex-col space-y-2"> <div className="text-xs font-medium text-foreground">Native model speed</div> <div className="flex justify-between"> - <span className="text-xs">0.5x</span> - <span className="text-xs font-bold">{Number.isInteger(localVoiceSpeed) ? localVoiceSpeed.toString() : localVoiceSpeed.toFixed(1)}x</span> - <span className="text-xs">3x</span> + <span className="text-xs">{min.toFixed(1)}x</span> + <span className="text-xs font-bold"> + {Number.isInteger(localVoiceSpeed) ? localVoiceSpeed.toString() : localVoiceSpeed.toFixed(1)}x + </span> + <span className="text-xs">{max.toFixed(1)}x</span> </div> <Input type="range" - min="0.5" - max="3" - step="0.1" + min={min} + max={max} + step={step} value={localVoiceSpeed} onChange={handleVoiceSpeedChange} onMouseUp={handleVoiceSpeedChangeComplete} @@ -83,19 +83,20 @@ export const SpeedControl = ({ /> </div> - {/* Audio Player Speed */} <div className="flex flex-col space-y-2"> <div className="text-xs font-medium text-foreground">Audio player speed</div> <div className="flex justify-between"> - <span className="text-xs">0.5x</span> - <span className="text-xs font-bold">{Number.isInteger(localAudioSpeed) ? localAudioSpeed.toString() : localAudioSpeed.toFixed(1)}x</span> - <span className="text-xs">3x</span> + <span className="text-xs">{min.toFixed(1)}x</span> + <span className="text-xs font-bold"> + {Number.isInteger(localAudioSpeed) ? localAudioSpeed.toString() : localAudioSpeed.toFixed(1)}x + </span> + <span className="text-xs">{max.toFixed(1)}x</span> </div> <Input type="range" - min="0.5" - max="3" - step="0.1" + min={min} + max={max} + step={step} value={localAudioSpeed} onChange={handleAudioSpeedChange} onMouseUp={handleAudioSpeedChangeComplete} @@ -108,4 +109,4 @@ export const SpeedControl = ({ </PopoverPanel> </Popover> ); -}; \ No newline at end of file +}; diff --git a/src/components/player/VoicesControl.tsx b/src/components/player/VoicesControl.tsx index 38d80f8..d854598 100644 --- a/src/components/player/VoicesControl.tsx +++ b/src/components/player/VoicesControl.tsx @@ -1,131 +1,23 @@ 'use client'; -import { - Listbox, - ListboxButton, - ListboxOption, - ListboxOptions, -} from '@headlessui/react'; -import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons'; import { useConfig } from '@/contexts/ConfigContext'; -import { useEffect, useMemo, useState } from 'react'; -import { parseKokoroVoiceNames, buildKokoroVoiceString, isKokoroModel, getMaxVoicesForProvider } from '@/utils/voice'; +import { useCallback } from 'react'; +import { VoicesControlBase } from '@/components/player/VoicesControlBase'; export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: { availableVoices: string[]; setVoiceAndRestart: (voice: string) => void; }) => { - const { voice: configVoice, ttsModel, ttsProvider } = useConfig(); - - const isKokoro = isKokoroModel(ttsModel); - const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel); - - // Local selection state for Kokoro multi-select - const [selectedVoices, setSelectedVoices] = useState<string[]>([]); - - useEffect(() => { - if (!(isKokoro && maxVoices > 1)) return; - let initial: string[] = []; - if (configVoice && configVoice.includes('+')) { - initial = parseKokoroVoiceNames(configVoice); - } else if (configVoice && availableVoices.includes(configVoice)) { - initial = [configVoice]; - } else if (availableVoices.length > 0) { - initial = [availableVoices[0]]; - } - // Clamp to provider limit - if (initial.length > maxVoices) { - initial = initial.slice(0, maxVoices); - } - setSelectedVoices(initial); - }, [isKokoro, maxVoices, configVoice, availableVoices]); - - // If the saved voice is not in the available list, use the first available voice (non-Kokoro or Kokoro limited) - const currentVoice = useMemo(() => { - if (isKokoro && maxVoices > 1) { - const combined = buildKokoroVoiceString(selectedVoices); - return combined || (availableVoices[0] || ''); - } - return (configVoice && availableVoices.includes(configVoice)) - ? configVoice - : availableVoices[0] || ''; - }, [isKokoro, maxVoices, selectedVoices, availableVoices, configVoice]); + const { voice, ttsModel, ttsProvider } = useConfig(); + const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]); return ( - <div className="relative"> - {(isKokoro && maxVoices > 1) ? ( - <Listbox - multiple - value={selectedVoices} - onChange={(vals: string[]) => { - if (!vals || vals.length === 0) return; // prevent empty selection - - let next = vals; - - // Enforce provider max selection - if (vals.length > maxVoices) { - const newlyAdded = vals.find(v => !selectedVoices.includes(v)); - if (newlyAdded) { - const lastPrev = selectedVoices[selectedVoices.length - 1] ?? selectedVoices[0] ?? ''; - const pair = Array.from(new Set([lastPrev, newlyAdded])).filter(Boolean); - next = pair.slice(0, maxVoices); - } else { - next = vals.slice(-maxVoices); - } - } - - setSelectedVoices(next); - const combined = buildKokoroVoiceString(next); - if (combined) { - setVoiceAndRestart(combined); - } - }} - > - <ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"> - <AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> - <span> - {selectedVoices.length > 1 - ? selectedVoices.join(' + ') - : selectedVoices[0] || currentVoice} - </span> - <ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> - </ListboxButton> - <ListboxOptions anchor='top end' className="absolute z-50 w-40 sm:w-44 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> - {availableVoices.map((voiceId) => ( - <ListboxOption - key={voiceId} - value={voiceId} - className={({ active, selected }) => - `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` - } - > - <span className='text-xs sm:text-sm'>{voiceId}</span> - </ListboxOption> - ))} - </ListboxOptions> - </Listbox> - ) : ( - <Listbox value={currentVoice} onChange={setVoiceAndRestart}> - <ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"> - <AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> - <span>{currentVoice}</span> - <ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> - </ListboxButton> - <ListboxOptions anchor='top end' className="absolute z-50 w-28 sm:w-32 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> - {availableVoices.map((voiceId) => ( - <ListboxOption - key={voiceId} - value={voiceId} - className={({ active, selected }) => - `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` - } - > - <span className='text-xs sm:text-sm'>{voiceId}</span> - </ListboxOption> - ))} - </ListboxOptions> - </Listbox> - )} - </div> + <VoicesControlBase + availableVoices={availableVoices} + voice={voice || ''} + onChangeVoice={onChangeVoice} + ttsProvider={ttsProvider} + ttsModel={ttsModel} + /> ); -} \ No newline at end of file +} diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx new file mode 100644 index 0000000..1501586 --- /dev/null +++ b/src/components/player/VoicesControlBase.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from '@headlessui/react'; +import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons'; +import { useEffect, useMemo, useState } from 'react'; +import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/utils/voice'; + +export function VoicesControlBase({ + availableVoices, + voice, + onChangeVoice, + ttsProvider, + ttsModel, +}: { + availableVoices: string[]; + voice: string; + onChangeVoice: (voice: string) => void; + ttsProvider: string; + ttsModel: string; +}) { + const isKokoro = isKokoroModel(ttsModel); + const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel); + + const [selectedVoices, setSelectedVoices] = useState<string[]>([]); + + useEffect(() => { + if (!(isKokoro && maxVoices > 1)) return; + let initial: string[] = []; + if (voice && voice.includes('+')) { + initial = parseKokoroVoiceNames(voice); + } else if (voice && availableVoices.includes(voice)) { + initial = [voice]; + } else if (availableVoices.length > 0) { + initial = [availableVoices[0]]; + } + if (initial.length > maxVoices) { + initial = initial.slice(0, maxVoices); + } + setSelectedVoices(initial); + }, [isKokoro, maxVoices, voice, availableVoices]); + + const currentVoice = useMemo(() => { + if (isKokoro && maxVoices > 1) { + const combined = buildKokoroVoiceString(selectedVoices); + return combined || (availableVoices[0] || ''); + } + return voice && availableVoices.includes(voice) ? voice : availableVoices[0] || ''; + }, [isKokoro, maxVoices, selectedVoices, availableVoices, voice]); + + if (availableVoices.length === 0) { + return ( + <div className="relative"> + <div className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-muted text-xs sm:text-sm rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1"> + <AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> + <span>No voices</span> + </div> + </div> + ); + } + + return ( + <div className="relative"> + {isKokoro && maxVoices > 1 ? ( + <Listbox + multiple + value={selectedVoices} + onChange={(vals: string[]) => { + if (!vals || vals.length === 0) return; + + let next = vals; + if (vals.length > maxVoices) { + const newlyAdded = vals.find((v) => !selectedVoices.includes(v)); + if (newlyAdded) { + const lastPrev = selectedVoices[selectedVoices.length - 1] ?? selectedVoices[0] ?? ''; + const pair = Array.from(new Set([lastPrev, newlyAdded])).filter(Boolean); + next = pair.slice(0, maxVoices); + } else { + next = vals.slice(-maxVoices); + } + } + + setSelectedVoices(next); + const combined = buildKokoroVoiceString(next); + if (combined) { + onChangeVoice(combined); + } + }} + > + <ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"> + <AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> + <span>{selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}</span> + <ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> + </ListboxButton> + <ListboxOptions anchor="top end" className="absolute z-50 w-40 sm:w-44 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> + {availableVoices.map((voiceId) => ( + <ListboxOption + key={voiceId} + value={voiceId} + className={({ active, selected }) => + `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` + } + > + <span className="text-xs sm:text-sm">{voiceId}</span> + </ListboxOption> + ))} + </ListboxOptions> + </Listbox> + ) : ( + <Listbox value={currentVoice} onChange={onChangeVoice}> + <ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"> + <AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> + <span>{currentVoice}</span> + <ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> + </ListboxButton> + <ListboxOptions anchor="top end" className="absolute z-50 w-28 sm:w-32 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> + {availableVoices.map((voiceId) => ( + <ListboxOption + key={voiceId} + value={voiceId} + className={({ active, selected }) => + `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` + } + > + <span className="text-xs sm:text-sm">{voiceId}</span> + </ListboxOption> + ))} + </ListboxOptions> + </Listbox> + )} + </div> + ); +} + diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 5089775..9b8a57e 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -1,9 +1,10 @@ 'use client'; -import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react'; +import { createContext, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db, initDB, updateAppConfig } from '@/lib/dexie'; +import { db, getDocumentIdMappings, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; +import toast from 'react-hot-toast'; export type { ViewType } from '@/types/config'; /** Configuration values for the application */ @@ -48,10 +49,32 @@ const ConfigContext = createContext<ConfigContextType | undefined>(undefined); export function ConfigProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(true); const [isDBReady, setIsDBReady] = useState(false); + const didRunStartupMigrations = useRef(false); // Helper function to generate provider-model key const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`; + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail; + const status = detail?.status; + if (status === 'opened') { + toast.dismiss('dexie-blocked'); + return; + } + if (status === 'blocked' || status === 'stalled') { + const message = + 'Database upgrade is waiting for another OpenReader tab. Close other OpenReader tabs and reload.'; + toast.error(message, { id: 'dexie-blocked', duration: Infinity }); + } + }; + + window.addEventListener('openreader:dexie', handler as EventListener); + return () => { + window.removeEventListener('openreader:dexie', handler as EventListener); + }; + }, []); + useEffect(() => { const initializeDB = async () => { try { @@ -68,6 +91,31 @@ export function ConfigProvider({ children }: { children: ReactNode }) { initializeDB(); }, []); + useEffect(() => { + if (!isDBReady) return; + if (didRunStartupMigrations.current) return; + didRunStartupMigrations.current = true; + + const run = async () => { + try { + await migrateLegacyDexieDocumentIdsToSha(); + const mappings = await getDocumentIdMappings(); + + // Run server-side v1 migrations proactively, since the client may now + // reference SHA-based IDs immediately after the Dexie migration. + await fetch('/api/migrations/v1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mappings }), + }).catch(() => undefined); + } catch (error) { + console.warn('Startup migrations failed:', error); + } + }; + + void run(); + }, [isDBReady]); + const appConfig = useLiveQuery( async () => { if (!isDBReady) return null; diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 365295f..f9e7db1 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -32,6 +32,7 @@ import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions, + AudiobookGenerationSettings, } from '@/types/client'; interface EPUBContextType { @@ -43,8 +44,21 @@ interface EPUBContextType { setCurrentDocument: (id: string) => Promise<void>; clearCurrDoc: () => void; extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>; - createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise<string>; - regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>; + createFullAudioBook: ( + onProgress: (progress: number) => void, + signal?: AbortSignal, + onChapterComplete?: (chapter: TTSAudiobookChapter) => void, + bookId?: string, + format?: TTSAudiobookFormat, + settings?: AudiobookGenerationSettings + ) => Promise<string>; + regenerateChapter: ( + chapterIndex: number, + bookId: string, + format: TTSAudiobookFormat, + signal: AbortSignal, + settings?: AudiobookGenerationSettings + ) => Promise<TTSAudiobookChapter>; bookRef: RefObject<Book | null>; renditionRef: RefObject<Rendition | undefined>; tocRef: RefObject<NavItem[]>; @@ -325,12 +339,26 @@ export function EPUBProvider({ children }: { children: ReactNode }) { signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, providedBookId?: string, - format: TTSAudiobookFormat = 'mp3' + format: TTSAudiobookFormat = 'mp3', + settings?: AudiobookGenerationSettings ): Promise<string> => { try { const sections = await extractBookText(); if (!sections.length) throw new Error('No text content found in book'); + const effectiveProvider = settings?.ttsProvider ?? ttsProvider; + const effectiveModel = settings?.ttsModel ?? ttsModel; + const effectiveVoice = + settings?.voice || + voice || + (effectiveProvider === 'openai' + ? 'alloy' + : effectiveProvider === 'deepinfra' + ? 'af_bella' + : 'af_sarah'); + const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed; + const effectiveFormat = settings?.format ?? format; + // Calculate total length for accurate progress tracking const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0); let processedLength = 0; @@ -406,16 +434,16 @@ export function EPUBProvider({ children }: { children: ReactNode }) { 'Content-Type': 'application/json', 'x-openai-key': apiKey, 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, + 'x-tts-provider': effectiveProvider, }; const reqBody: TTSRequestPayload = { text: trimmedText, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, + voice: effectiveVoice, + speed: effectiveNativeSpeed, format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + model: effectiveModel, + instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; const retryOptions: TTSRetryOptions = { @@ -459,8 +487,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) { chapterTitle, buffer: Array.from(new Uint8Array(audioBuffer)), bookId, - format, - chapterIndex: i + format: effectiveFormat, + chapterIndex: i, + settings }, signal); if (!bookId) { @@ -492,7 +521,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { title: sectionTitleMap.get(section.href) || `Chapter ${i + 1}`, status: 'error', bookId, - format + format: effectiveFormat }); } } @@ -516,7 +545,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) { chapterIndex: number, bookId: string, format: TTSAudiobookFormat, - signal: AbortSignal + signal: AbortSignal, + settings?: AudiobookGenerationSettings ): Promise<TTSAudiobookChapter> => { try { const sections = await extractBookText(); @@ -524,6 +554,19 @@ export function EPUBProvider({ children }: { children: ReactNode }) { throw new Error('Invalid chapter index'); } + const effectiveProvider = settings?.ttsProvider ?? ttsProvider; + const effectiveModel = settings?.ttsModel ?? ttsModel; + const effectiveVoice = + settings?.voice || + voice || + (effectiveProvider === 'openai' + ? 'alloy' + : effectiveProvider === 'deepinfra' + ? 'af_bella' + : 'af_sarah'); + const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed; + const effectiveFormat = settings?.format ?? format; + const section = sections[chapterIndex]; const trimmedText = section.text.trim(); @@ -557,16 +600,16 @@ export function EPUBProvider({ children }: { children: ReactNode }) { 'Content-Type': 'application/json', 'x-openai-key': apiKey, 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, + 'x-tts-provider': effectiveProvider, }; const reqBody: TTSRequestPayload = { text: trimmedText, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, + voice: effectiveVoice, + speed: effectiveNativeSpeed, format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + model: effectiveModel, + instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; const retryOptions: TTSRetryOptions = { @@ -596,8 +639,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) { chapterTitle, buffer: Array.from(new Uint8Array(audioBuffer)), bookId, - format, - chapterIndex + format: effectiveFormat, + chapterIndex, + settings }, signal); return chapter; diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 72a2340..a771452 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -50,6 +50,7 @@ import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions, + AudiobookGenerationSettings, } from '@/types/client'; /** @@ -77,8 +78,21 @@ interface PDFContextType { sentence: string | null | undefined, containerRef: RefObject<HTMLDivElement> ) => void; - createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise<string>; - regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>; + createFullAudioBook: ( + onProgress: (progress: number) => void, + signal?: AbortSignal, + onChapterComplete?: (chapter: TTSAudiobookChapter) => void, + bookId?: string, + format?: TTSAudiobookFormat, + settings?: AudiobookGenerationSettings + ) => Promise<string>; + regenerateChapter: ( + chapterIndex: number, + bookId: string, + format: TTSAudiobookFormat, + signal: AbortSignal, + settings?: AudiobookGenerationSettings + ) => Promise<TTSAudiobookChapter>; isAudioCombining: boolean; } @@ -264,13 +278,27 @@ export function PDFProvider({ children }: { children: ReactNode }) { signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, providedBookId?: string, - format: TTSAudiobookFormat = 'mp3' + format: TTSAudiobookFormat = 'mp3', + settings?: AudiobookGenerationSettings ): Promise<string> => { try { if (!pdfDocument) { throw new Error('No PDF document loaded'); } + const effectiveProvider = settings?.ttsProvider ?? ttsProvider; + const effectiveModel = settings?.ttsModel ?? ttsModel; + const effectiveVoice = + settings?.voice || + voice || + (effectiveProvider === 'openai' + ? 'alloy' + : effectiveProvider === 'deepinfra' + ? 'af_bella' + : 'af_sarah'); + const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed; + const effectiveFormat = settings?.format ?? format; + // First pass: extract and measure all text const textPerPage: string[] = []; let totalLength = 0; @@ -342,16 +370,16 @@ export function PDFProvider({ children }: { children: ReactNode }) { 'Content-Type': 'application/json', 'x-openai-key': apiKey, 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, + 'x-tts-provider': effectiveProvider, }; const reqBody: TTSRequestPayload = { text, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, + voice: effectiveVoice, + speed: effectiveNativeSpeed, format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + model: effectiveModel, + instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; const retryOptions: TTSRetryOptions = { @@ -390,8 +418,9 @@ export function PDFProvider({ children }: { children: ReactNode }) { chapterTitle, buffer: Array.from(new Uint8Array(audioBuffer)), bookId, - format, - chapterIndex: i + format: effectiveFormat, + chapterIndex: i, + settings }, signal); if (!bookId) { @@ -423,7 +452,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { title: `Page ${i + 1}`, status: 'error', bookId, - format + format: effectiveFormat }); } } @@ -447,13 +476,27 @@ export function PDFProvider({ children }: { children: ReactNode }) { chapterIndex: number, bookId: string, format: TTSAudiobookFormat, - signal: AbortSignal + signal: AbortSignal, + settings?: AudiobookGenerationSettings ): Promise<TTSAudiobookChapter> => { try { if (!pdfDocument) { throw new Error('No PDF document loaded'); } + const effectiveProvider = settings?.ttsProvider ?? ttsProvider; + const effectiveModel = settings?.ttsModel ?? ttsModel; + const effectiveVoice = + settings?.voice || + voice || + (effectiveProvider === 'openai' + ? 'alloy' + : effectiveProvider === 'deepinfra' + ? 'af_bella' + : 'af_sarah'); + const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed; + const effectiveFormat = settings?.format ?? format; + // IMPORTANT: Chapter indices are based on non-empty pages used during generation. // Build a mapping of "chapterIndex" -> actual PDF page number (1-based). const nonEmptyPages: number[] = []; @@ -500,16 +543,16 @@ export function PDFProvider({ children }: { children: ReactNode }) { 'Content-Type': 'application/json', 'x-openai-key': apiKey, 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, + 'x-tts-provider': effectiveProvider, }; const reqBody: TTSRequestPayload = { text: textForTTS, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, + voice: effectiveVoice, + speed: effectiveNativeSpeed, format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + model: effectiveModel, + instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; const retryOptions: TTSRetryOptions = { @@ -539,8 +582,9 @@ export function PDFProvider({ children }: { children: ReactNode }) { chapterTitle, buffer: Array.from(new Uint8Array(audioBuffer)), bookId, - format, - chapterIndex + format: effectiveFormat, + chapterIndex, + settings }, signal); return chapter; diff --git a/src/hooks/epub/useEPUBDocuments.ts b/src/hooks/epub/useEPUBDocuments.ts index d09a170..6e58477 100644 --- a/src/hooks/epub/useEPUBDocuments.ts +++ b/src/hooks/epub/useEPUBDocuments.ts @@ -1,10 +1,10 @@ 'use client'; import { useCallback } from 'react'; -import { v4 as uuidv4 } from 'uuid'; import { useLiveQuery } from 'dexie-react-hooks'; import { db } from '@/lib/dexie'; import type { EPUBDocument } from '@/types/documents'; +import { sha256HexFromArrayBuffer } from '@/lib/sha256'; export function useEPUBDocuments() { const documents = useLiveQuery( @@ -16,8 +16,8 @@ export function useEPUBDocuments() { const isLoading = documents === undefined; const addDocument = useCallback(async (file: File): Promise<string> => { - const id = uuidv4(); const arrayBuffer = await file.arrayBuffer(); + const id = await sha256HexFromArrayBuffer(arrayBuffer); console.log('Original file size:', file.size); console.log('ArrayBuffer size:', arrayBuffer.byteLength); @@ -31,7 +31,7 @@ export function useEPUBDocuments() { data: arrayBuffer, }; - await db['epub-documents'].add(newDoc); + await db['epub-documents'].put(newDoc); return id; }, []); diff --git a/src/hooks/html/useHTMLDocuments.ts b/src/hooks/html/useHTMLDocuments.ts index 593ef5d..b5d620c 100644 --- a/src/hooks/html/useHTMLDocuments.ts +++ b/src/hooks/html/useHTMLDocuments.ts @@ -1,10 +1,10 @@ 'use client'; import { useCallback } from 'react'; -import { v4 as uuidv4 } from 'uuid'; import { useLiveQuery } from 'dexie-react-hooks'; import { db } from '@/lib/dexie'; import type { HTMLDocument } from '@/types/documents'; +import { sha256HexFromString } from '@/lib/sha256'; export function useHTMLDocuments() { const documents = useLiveQuery( @@ -16,8 +16,10 @@ export function useHTMLDocuments() { const isLoading = documents === undefined; const addDocument = useCallback(async (file: File): Promise<string> => { - const id = uuidv4(); - const content = await file.text(); + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const content = new TextDecoder().decode(bytes); + const id = await sha256HexFromString(content); const newDoc: HTMLDocument = { id, @@ -28,7 +30,7 @@ export function useHTMLDocuments() { data: content, }; - await db['html-documents'].add(newDoc); + await db['html-documents'].put(newDoc); return id; }, []); diff --git a/src/hooks/pdf/usePDFDocuments.ts b/src/hooks/pdf/usePDFDocuments.ts index bfe3425..04047e2 100644 --- a/src/hooks/pdf/usePDFDocuments.ts +++ b/src/hooks/pdf/usePDFDocuments.ts @@ -1,10 +1,10 @@ 'use client'; import { useCallback } from 'react'; -import { v4 as uuidv4 } from 'uuid'; import { useLiveQuery } from 'dexie-react-hooks'; import { db } from '@/lib/dexie'; import type { PDFDocument } from '@/types/documents'; +import { sha256HexFromArrayBuffer } from '@/lib/sha256'; export function usePDFDocuments() { const documents = useLiveQuery( @@ -16,8 +16,8 @@ export function usePDFDocuments() { const isLoading = documents === undefined; const addDocument = useCallback(async (file: File): Promise<string> => { - const id = uuidv4(); const arrayBuffer = await file.arrayBuffer(); + const id = await sha256HexFromArrayBuffer(arrayBuffer); const newDoc: PDFDocument = { id, @@ -28,7 +28,7 @@ export function usePDFDocuments() { data: arrayBuffer, }; - await db['pdf-documents'].add(newDoc); + await db['pdf-documents'].put(newDoc); return id; }, []); diff --git a/src/lib/client.ts b/src/lib/client.ts index f6ca16d..4481f36 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -116,8 +116,14 @@ export const createAudiobookChapter = async ( throw new Error('cancelled'); } + if (response.status === 409) { + const data = await response.json().catch(() => null) as { error?: string } | null; + throw new Error(data?.error || 'Audiobook settings mismatch'); + } + if (!response.ok) { - throw new Error('Failed to convert audio chapter'); + const data = await response.json().catch(() => null) as { error?: string } | null; + throw new Error(data?.error || 'Failed to convert audio chapter'); } return await response.json(); diff --git a/src/lib/dexie.ts b/src/lib/dexie.ts index 3fe1d72..9b671e6 100644 --- a/src/lib/dexie.ts +++ b/src/lib/dexie.ts @@ -1,10 +1,19 @@ import Dexie, { type EntityTable } from 'dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config'; -import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents'; +import { + PDFDocument, + EPUBDocument, + HTMLDocument, + DocumentListState, + SyncedDocument, + BaseDocument, + DocumentListDocument, +} from '@/types/documents'; +import { sha256HexFromBytes, sha256HexFromString } from '@/lib/sha256'; const DB_NAME = 'openreader-db'; // Managed via Dexie (version bumped from the original manual IndexedDB) -const DB_VERSION = 5; +const DB_VERSION = 6; const PDF_TABLE = 'pdf-documents' as const; const EPUB_TABLE = 'epub-documents' as const; @@ -12,12 +21,19 @@ const HTML_TABLE = 'html-documents' as const; const CONFIG_TABLE = 'config' as const; const APP_CONFIG_TABLE = 'app-config' as const; const LAST_LOCATION_TABLE = 'last-locations' as const; +const DOCUMENT_ID_MAP_TABLE = 'document-id-map' as const; export interface LastLocationRow { docId: string; location: string; } +export interface DocumentIdMapRow { + oldId: string; + id: string; + createdAt: number; +} + export interface ConfigRow { key: string; value: string; @@ -30,12 +46,35 @@ type OpenReaderDB = Dexie & { [CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>; [APP_CONFIG_TABLE]: EntityTable<AppConfigRow, 'id'>; [LAST_LOCATION_TABLE]: EntityTable<LastLocationRow, 'docId'>; + [DOCUMENT_ID_MAP_TABLE]: EntityTable<DocumentIdMapRow, 'oldId'>; }; export const db = new Dexie(DB_NAME) as OpenReaderDB; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; +type DexieOpenStatus = 'opening' | 'opened' | 'blocked' | 'stalled' | 'error'; + +function emitDexieStatus(status: DexieOpenStatus, detail?: Record<string, unknown>): void { + if (typeof window === 'undefined') return; + try { + window.dispatchEvent( + new CustomEvent('openreader:dexie', { + detail: { status, ...detail }, + }), + ); + } catch { + // ignore + } +} + +if (typeof window !== 'undefined') { + // Fired when this tab's open/upgrade is blocked by another tab holding the DB open. + db.on('blocked', () => { + emitDexieStatus('blocked'); + }); +} + const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = { openai: 'https://api.openai.com/v1', deepinfra: 'https://api.deepinfra.com/v1/openai', @@ -152,7 +191,7 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow { return config; } -// Version 5: introduce app-config and last-locations tables, migrate scattered config keys, +// Version 6: add document-id-map table; keep v5 upgrade to migrate scattered config keys // and drop the legacy config table in a single upgrade step. db.version(DB_VERSION).stores({ [PDF_TABLE]: 'id, type, name, lastModified, size, folderId', @@ -160,6 +199,7 @@ db.version(DB_VERSION).stores({ [HTML_TABLE]: 'id, type, name, lastModified, size, folderId', [APP_CONFIG_TABLE]: 'id', [LAST_LOCATION_TABLE]: 'docId', + [DOCUMENT_ID_MAP_TABLE]: 'oldId, id, createdAt', // `null` here means: drop the old 'config' table after upgrade runs, // but Dexie still lets us read it inside the upgrade transaction. [CONFIG_TABLE]: null, @@ -199,10 +239,18 @@ export async function initDB(): Promise<void> { dbOpenPromise = (async () => { try { console.log('Opening Dexie database...'); + emitDexieStatus('opening'); + const startedAt = Date.now(); + const stallTimer = setTimeout(() => { + emitDexieStatus('stalled', { ms: Date.now() - startedAt }); + }, 4000); await db.open(); + clearTimeout(stallTimer); console.log('Dexie database opened successfully'); + emitDexieStatus('opened'); } catch (error) { console.error('Dexie initialization error:', error); + emitDexieStatus('error', { message: error instanceof Error ? error.message : String(error) }); dbOpenPromise = null; throw error; } @@ -216,6 +264,193 @@ async function withDB<T>(operation: () => Promise<T>): Promise<T> { return operation(); } +function isSha256HexId(value: string): boolean { + return /^[a-f0-9]{64}$/i.test(value); +} + +async function getMappedDocumentId(docId: string): Promise<string> { + if (isSha256HexId(docId)) return docId.toLowerCase(); + const row = await db[DOCUMENT_ID_MAP_TABLE].get(docId); + return row?.id ?? docId; +} + +export async function resolveDocumentId(docId: string): Promise<string> { + return withDB(async () => getMappedDocumentId(docId)); +} + +async function recordDocumentIdMapping(oldId: string, id: string): Promise<void> { + if (oldId === id) return; + await db[DOCUMENT_ID_MAP_TABLE].put({ oldId, id, createdAt: Date.now() }); +} + +function rewriteDocumentListStateDocIds(state: DocumentListState, mapping: Map<string, string>): DocumentListState { + let didChange = false; + + const folders = state.folders.map((folder) => { + let folderChanged = false; + const seen = new Set<string>(); + const documents: DocumentListDocument[] = []; + + for (const doc of folder.documents) { + const mappedId = mapping.get(doc.id) ?? doc.id; + if (mappedId !== doc.id) folderChanged = true; + if (seen.has(mappedId)) { + folderChanged = true; + continue; + } + seen.add(mappedId); + documents.push(mappedId === doc.id ? doc : { ...doc, id: mappedId }); + } + + if (!folderChanged) return folder; + didChange = true; + return { ...folder, documents }; + }); + + return didChange ? { ...state, folders } : state; +} + +async function applyDocumentIdMapping(oldId: string, newId: string): Promise<void> { + if (!oldId || !newId || oldId === newId) return; + const nextId = newId.toLowerCase(); + + await withDB(async () => { + await db.transaction( + 'readwrite', + [ + db[PDF_TABLE], + db[EPUB_TABLE], + db[HTML_TABLE], + db[LAST_LOCATION_TABLE], + db[APP_CONFIG_TABLE], + db[DOCUMENT_ID_MAP_TABLE], + ], + async () => { + await recordDocumentIdMapping(oldId, nextId); + + const pdf = await db[PDF_TABLE].get(oldId); + if (pdf) { + const existing = await db[PDF_TABLE].get(nextId); + if (existing) { + const merged: PDFDocument = { + ...pdf, + ...existing, + id: nextId, + folderId: existing.folderId ?? pdf.folderId, + name: existing.name || pdf.name, + }; + await db[PDF_TABLE].put(merged); + await db[PDF_TABLE].delete(oldId); + } else { + await db[PDF_TABLE].put({ ...pdf, id: nextId }); + await db[PDF_TABLE].delete(oldId); + } + } + + const epub = await db[EPUB_TABLE].get(oldId); + if (epub) { + const existing = await db[EPUB_TABLE].get(nextId); + if (existing) { + const merged: EPUBDocument = { + ...epub, + ...existing, + id: nextId, + folderId: existing.folderId ?? epub.folderId, + name: existing.name || epub.name, + }; + await db[EPUB_TABLE].put(merged); + await db[EPUB_TABLE].delete(oldId); + } else { + await db[EPUB_TABLE].put({ ...epub, id: nextId }); + await db[EPUB_TABLE].delete(oldId); + } + } + + const html = await db[HTML_TABLE].get(oldId); + if (html) { + const existing = await db[HTML_TABLE].get(nextId); + if (existing) { + const merged: HTMLDocument = { + ...html, + ...existing, + id: nextId, + folderId: existing.folderId ?? html.folderId, + name: existing.name || html.name, + }; + await db[HTML_TABLE].put(merged); + await db[HTML_TABLE].delete(oldId); + } else { + await db[HTML_TABLE].put({ ...html, id: nextId }); + await db[HTML_TABLE].delete(oldId); + } + } + + const oldLocation = await db[LAST_LOCATION_TABLE].get(oldId); + if (oldLocation) { + const newLocation = await db[LAST_LOCATION_TABLE].get(nextId); + if (!newLocation) { + await db[LAST_LOCATION_TABLE].put({ docId: nextId, location: oldLocation.location }); + } + await db[LAST_LOCATION_TABLE].delete(oldId); + } + + const appConfig = await db[APP_CONFIG_TABLE].get('singleton'); + if (appConfig?.documentListState) { + const mapped = rewriteDocumentListStateDocIds(appConfig.documentListState, new Map([[oldId, nextId]])); + if (mapped !== appConfig.documentListState) { + await db[APP_CONFIG_TABLE].update('singleton', { documentListState: mapped }); + } + } + }, + ); + }); +} + +export async function migrateLegacyDexieDocumentIdsToSha(): Promise<Array<{ oldId: string; id: string }>> { + return withDB(async () => { + const mappings: Array<{ oldId: string; id: string }> = []; + + const pdfDocs = await db[PDF_TABLE].toArray(); + for (const doc of pdfDocs) { + if (isSha256HexId(doc.id)) continue; + const id = await sha256HexFromBytes(new Uint8Array(doc.data)); + if (id !== doc.id) { + mappings.push({ oldId: doc.id, id }); + await applyDocumentIdMapping(doc.id, id); + } + } + + const epubDocs = await db[EPUB_TABLE].toArray(); + for (const doc of epubDocs) { + if (isSha256HexId(doc.id)) continue; + const id = await sha256HexFromBytes(new Uint8Array(doc.data)); + if (id !== doc.id) { + mappings.push({ oldId: doc.id, id }); + await applyDocumentIdMapping(doc.id, id); + } + } + + const htmlDocs = await db[HTML_TABLE].toArray(); + for (const doc of htmlDocs) { + if (isSha256HexId(doc.id)) continue; + const id = await sha256HexFromString(doc.data); + if (id !== doc.id) { + mappings.push({ oldId: doc.id, id }); + await applyDocumentIdMapping(doc.id, id); + } + } + + return mappings; + }); +} + +export async function getDocumentIdMappings(): Promise<Array<{ oldId: string; id: string }>> { + return withDB(async () => { + const rows = await db[DOCUMENT_ID_MAP_TABLE].toArray(); + return rows.map((row) => ({ oldId: row.oldId, id: row.id })); + }); +} + // PDF document helpers export async function addPdfDocument(document: PDFDocument): Promise<void> { @@ -228,7 +463,8 @@ export async function addPdfDocument(document: PDFDocument): Promise<void> { 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); + const resolved = await getMappedDocumentId(id); + return db[PDF_TABLE].get(resolved); }); } @@ -242,9 +478,10 @@ export async function getAllPdfDocuments(): Promise<PDFDocument[]> { export async function removePdfDocument(id: string): Promise<void> { await withDB(async () => { console.log('Removing PDF document via Dexie:', id); + const resolved = await getMappedDocumentId(id); await db.transaction('readwrite', db[PDF_TABLE], db[LAST_LOCATION_TABLE], async () => { - await db[PDF_TABLE].delete(id); - await db[LAST_LOCATION_TABLE].delete(id); + await db[PDF_TABLE].delete(resolved); + await db[LAST_LOCATION_TABLE].delete(resolved); }); }); } @@ -277,7 +514,8 @@ export async function addEpubDocument(document: EPUBDocument): Promise<void> { 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); + const resolved = await getMappedDocumentId(id); + return db[EPUB_TABLE].get(resolved); }); } @@ -291,9 +529,10 @@ export async function getAllEpubDocuments(): Promise<EPUBDocument[]> { export async function removeEpubDocument(id: string): Promise<void> { await withDB(async () => { console.log('Removing EPUB document via Dexie:', id); + const resolved = await getMappedDocumentId(id); await db.transaction('readwrite', db[EPUB_TABLE], db[LAST_LOCATION_TABLE], async () => { - await db[EPUB_TABLE].delete(id); - await db[LAST_LOCATION_TABLE].delete(id); + await db[EPUB_TABLE].delete(resolved); + await db[LAST_LOCATION_TABLE].delete(resolved); }); }); } @@ -317,7 +556,8 @@ export async function addHtmlDocument(document: HTMLDocument): Promise<void> { 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); + const resolved = await getMappedDocumentId(id); + return db[HTML_TABLE].get(resolved); }); } @@ -331,7 +571,8 @@ export async function getAllHtmlDocuments(): Promise<HTMLDocument[]> { 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); + const resolved = await getMappedDocumentId(id); + await db[HTML_TABLE].delete(resolved); }); } @@ -382,14 +623,16 @@ export async function getDocumentListState(): Promise<DocumentListState | null> export async function getLastDocumentLocation(docId: string): Promise<string | null> { return withDB(async () => { - const row = await db[LAST_LOCATION_TABLE].get(docId); + const resolved = await getMappedDocumentId(docId); + const row = await db[LAST_LOCATION_TABLE].get(resolved); return row ? row.location : null; }); } export async function setLastDocumentLocation(docId: string, location: string): Promise<void> { await withDB(async () => { - await db[LAST_LOCATION_TABLE].put({ docId, location }); + const resolved = await getMappedDocumentId(docId); + await db[LAST_LOCATION_TABLE].put({ docId: resolved, location }); }); } @@ -471,6 +714,16 @@ export async function syncDocumentsToServer( throw new Error('Failed to sync documents to server'); } + const payload = (await response.json().catch(() => null)) as + | { stored?: Array<{ oldId: string; id: string }> } + | null; + const stored = payload?.stored ?? []; + for (const mapping of stored) { + if (!mapping || typeof mapping.oldId !== 'string' || typeof mapping.id !== 'string') continue; + if (mapping.oldId === mapping.id) continue; + await applyDocumentIdMapping(mapping.oldId, mapping.id); + } + if (onProgress) { onProgress(100, 'Upload complete!'); } @@ -555,3 +808,90 @@ export async function loadDocumentsFromServer( return { lastSync: Date.now() }; } + +export async function importDocumentsFromLibrary( + onProgress?: (progress: number, status?: string) => void, + signal?: AbortSignal, +): Promise<void> { + if (onProgress) { + onProgress(5, 'Scanning server library...'); + } + + const listResponse = await fetch('/api/documents/library', { signal }); + if (!listResponse.ok) { + throw new Error('Failed to list library documents'); + } + + const { documents } = (await listResponse.json()) as { documents: BaseDocument[] }; + + if (documents.length === 0) { + if (onProgress) { + onProgress(100, 'No documents found in server library'); + } + return; + } + + if (onProgress) { + onProgress(10, `Found ${documents.length} documents. Importing...`); + } + + const textDecoder = new TextDecoder(); + + for (let i = 0; i < documents.length; i++) { + const doc = documents[i]; + + if (onProgress) { + onProgress(10 + (i / documents.length) * 85, `Downloading ${i + 1}/${documents.length}: ${doc.name}`); + } + + const contentResponse = await fetch(`/api/documents/library/content?id=${encodeURIComponent(doc.id)}`, { signal }); + if (!contentResponse.ok) { + console.warn(`Failed to download library document: ${doc.name}`); + continue; + } + + const buffer = await contentResponse.arrayBuffer(); + const bytes = new Uint8Array(buffer); + + if (doc.type === 'pdf') { + const localId = await sha256HexFromBytes(bytes); + await addPdfDocument({ + id: localId, + type: 'pdf', + name: doc.name, + size: bytes.byteLength, + lastModified: doc.lastModified, + data: buffer, + }); + } else if (doc.type === 'epub') { + const localId = await sha256HexFromBytes(bytes); + await addEpubDocument({ + id: localId, + type: 'epub', + name: doc.name, + size: bytes.byteLength, + lastModified: doc.lastModified, + data: buffer, + }); + } else { + const decoded = textDecoder.decode(bytes); + const localId = await sha256HexFromString(decoded); + await addHtmlDocument({ + id: localId, + type: 'html', + name: doc.name, + size: bytes.byteLength, + lastModified: doc.lastModified, + data: decoded, + }); + } + + if (onProgress) { + onProgress(10 + ((i + 1) / documents.length) * 85, `Imported ${i + 1}/${documents.length}`); + } + } + + if (onProgress) { + onProgress(100, 'Library import complete!'); + } +} diff --git a/src/lib/pdf.ts b/src/lib/pdf.ts index fc4c120..6f157cc 100644 --- a/src/lib/pdf.ts +++ b/src/lib/pdf.ts @@ -155,6 +155,33 @@ let lastSentenceTokenWindow: { start: number; end: number } | null = null; let lastSentencePattern: string | null = null; let lastSentenceWordToTokenMap: number[] | null = null; +function getOrCreateHighlightLayer(span: HTMLElement): { + layer: HTMLElement; + pageElement: HTMLElement; + pageRect: DOMRect; +} | null { + const pageElement = span.closest('.react-pdf__Page') as HTMLElement | null; + if (!pageElement) return null; + + let layer = pageElement.querySelector('.pdf-highlight-layer') as HTMLElement | null; + if (!layer) { + layer = document.createElement('div'); + layer.className = 'pdf-highlight-layer'; + pageElement.appendChild(layer); + } + + layer.style.position = 'absolute'; + layer.style.inset = '0'; + layer.style.pointerEvents = 'none'; + layer.style.zIndex = '4'; + layer.style.overflow = 'hidden'; + // Force a compositor layer to avoid Safari occasionally not painting + // newly-added positioned overlays. + layer.style.transform = 'translateZ(0)'; + + return { layer, pageElement, pageRect: pageElement.getBoundingClientRect() }; +} + const normalizeWordForMatch = (text: string): string => text .trim() @@ -453,12 +480,10 @@ export function highlightPattern( range.setStart(textNode, startOffset); range.setEnd(textNode, endOffset); - const pageLayer = span.closest( - '.react-pdf__Page__textContent' - ) as HTMLElement | null; - if (!pageLayer) return; + const highlightTarget = getOrCreateHighlightLayer(span); + if (!highlightTarget) return; - const pageRect = pageLayer.getBoundingClientRect(); + const { layer: highlightLayer, pageRect } = highlightTarget; const rects = Array.from(range.getClientRects()); rects.forEach((rect) => { @@ -468,11 +493,12 @@ export function highlightPattern( highlight.style.backgroundColor = 'grey'; highlight.style.opacity = '0.4'; highlight.style.pointerEvents = 'none'; + highlight.style.zIndex = '1'; highlight.style.left = `${rect.left - pageRect.left}px`; highlight.style.top = `${rect.top - pageRect.top}px`; highlight.style.width = `${rect.width}px`; highlight.style.height = `${rect.height}px`; - pageLayer.appendChild(highlight); + highlightLayer.appendChild(highlight); scrollIntoViewRects.push(rect); }); @@ -688,12 +714,10 @@ export function highlightWordIndex( range.setStart(node, token.startOffset); range.setEnd(node, token.endOffset); - const pageLayer = span.closest( - '.react-pdf__Page__textContent' - ) as HTMLElement | null; - if (!pageLayer) return; + const highlightTarget = getOrCreateHighlightLayer(span); + if (!highlightTarget) return; - const pageRect = pageLayer.getBoundingClientRect(); + const { layer: highlightLayer, pageRect } = highlightTarget; const rects = Array.from(range.getClientRects()); rects.forEach((rect) => { @@ -708,7 +732,7 @@ export function highlightWordIndex( highlight.style.width = `${rect.width}px`; highlight.style.height = `${rect.height}px`; highlight.style.zIndex = '2'; - pageLayer.appendChild(highlight); + highlightLayer.appendChild(highlight); }); } catch { // Ignore range errors diff --git a/src/lib/server/audiobook.ts b/src/lib/server/audiobook.ts new file mode 100644 index 0000000..2ef5c7a --- /dev/null +++ b/src/lib/server/audiobook.ts @@ -0,0 +1,215 @@ +import { spawn } from 'child_process'; +import path from 'path'; +import { readdir } from 'fs/promises'; + +export type StoredChapter = { + index: number; + title: string; + durationSec?: number; + format: 'mp3' | 'm4b'; + filePath: string; +}; + +function sanitizeTagValue(value: string): string { + return value.replaceAll('\u0000', '').replaceAll(/\r?\n/g, ' ').trim(); +} + +function sanitizeFileStem(value: string): string { + return sanitizeTagValue(value) + .replaceAll(/[\\/]/g, ' ') + .replaceAll(/[<>:"|?*\u0000]/g, '') + .replaceAll(/\s+/g, ' ') + .trim() + .slice(0, 180); +} + +export function encodeChapterTitleTag(index: number, title: string): string { + const safeTitle = sanitizeTagValue(title) || `Chapter ${index + 1}`; + const prefix = String(index + 1).padStart(4, '0'); + return `${prefix} - ${safeTitle}`; +} + +export function decodeChapterTitleTag(tag: string): { index: number; title: string } | null { + const raw = sanitizeTagValue(tag); + if (!raw) return null; + + const match = /^(\d{1,6})\s*[-.:]\s*(.+)$/.exec(raw); + if (!match) return null; + + const oneBased = Number(match[1]); + if (!Number.isFinite(oneBased) || !Number.isInteger(oneBased) || oneBased <= 0) return null; + + return { index: oneBased - 1, title: match[2].trim() || `Chapter ${oneBased}` }; +} + +export function encodeChapterFileName(index: number, title: string, format: 'mp3' | 'm4b'): string { + const oneBased = String(index + 1).padStart(4, '0'); + const safeTitle = sanitizeFileStem(title) || `Chapter ${index + 1}`; + return `${oneBased}__${encodeURIComponent(safeTitle)}.${format}`; +} + +function decodeChapterFileName(fileName: string): { index: number; title: string; format: 'mp3' | 'm4b' } | null { + const match = /^(\d{1,6})__(.+)\.(mp3|m4b)$/i.exec(fileName); + if (!match) return null; + const oneBased = Number(match[1]); + if (!Number.isInteger(oneBased) || oneBased <= 0) return null; + const format = match[3].toLowerCase() as 'mp3' | 'm4b'; + try { + const title = decodeURIComponent(match[2]); + return { index: oneBased - 1, title: title || `Chapter ${oneBased}`, format }; + } catch { + return { index: oneBased - 1, title: match[2], format }; + } +} + +type ProbeResult = { + durationSec?: number; + titleTag?: string; +}; + +export async function ffprobeAudio(filePath: string, signal?: AbortSignal): Promise<ProbeResult> { + return new Promise<ProbeResult>((resolve, reject) => { + const ffprobe = spawn('ffprobe', [ + '-v', + 'quiet', + '-print_format', + 'json', + '-show_entries', + 'format=duration:format_tags=title', + filePath, + ]); + + let output = ''; + let finished = false; + + const onAbort = () => { + if (finished) return; + finished = true; + try { + ffprobe.kill('SIGKILL'); + } catch {} + reject(new Error('ABORTED')); + }; + + const cleanup = () => { + if (finished) return; + finished = true; + signal?.removeEventListener('abort', onAbort); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + + ffprobe.stdout.on('data', (data) => { + output += data.toString(); + }); + + ffprobe.on('close', (code) => { + if (finished) return; + cleanup(); + if (code !== 0) { + reject(new Error(`ffprobe process exited with code ${code}`)); + return; + } + + try { + const parsed = JSON.parse(output) as { + format?: { duration?: string; tags?: { title?: string } }; + }; + const durationStr = parsed.format?.duration; + const durationSec = durationStr ? Number(durationStr) : undefined; + resolve({ + durationSec: Number.isFinite(durationSec) ? durationSec : undefined, + titleTag: parsed.format?.tags?.title, + }); + } catch (error) { + reject(error); + } + }); + + ffprobe.on('error', (err) => { + if (finished) return; + cleanup(); + reject(err); + }); + }); +} + +export async function listStoredChapters(dir: string, signal?: AbortSignal): Promise<StoredChapter[]> { + let files: string[] = []; + try { + files = await readdir(dir); + } catch { + return []; + } + + const candidates = files.filter((file) => !file.startsWith('.')).filter((file) => !file.startsWith('complete.')); + + const results: StoredChapter[] = []; + for (const file of candidates) { + const decodedFromName = decodeChapterFileName(file); + if (!decodedFromName) continue; + + const filePath = path.join(dir, file); + + let durationSec: number | undefined; + try { + const probe = await ffprobeAudio(filePath, signal); + durationSec = probe.durationSec; + } catch {} + + results.push({ + index: decodedFromName.index, + title: decodedFromName.title, + durationSec, + format: decodedFromName.format, + filePath, + }); + } + + results.sort((a, b) => a.index - b.index); + return results; +} + +export async function findStoredChapterByIndex( + dir: string, + index: number, + signal?: AbortSignal, +): Promise<StoredChapter | null> { + let files: string[] = []; + try { + files = await readdir(dir); + } catch { + return null; + } + + const oneBasedPrefix = String(index + 1).padStart(4, '0') + '__'; + const candidate = files.find((file) => file.startsWith(oneBasedPrefix) && (file.endsWith('.mp3') || file.endsWith('.m4b'))); + if (!candidate) { + const chapters = await listStoredChapters(dir, signal); + return chapters.find((chapter) => chapter.index === index) ?? null; + } + + const decoded = decodeChapterFileName(candidate); + if (!decoded) return null; + + const filePath = path.join(dir, candidate); + let durationSec: number | undefined; + try { + const probe = await ffprobeAudio(filePath, signal); + durationSec = probe.durationSec; + } catch {} + + return { + index: decoded.index, + title: decoded.title, + durationSec, + format: decoded.format, + filePath, + }; +} diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts new file mode 100644 index 0000000..b6dd3da --- /dev/null +++ b/src/lib/server/docstore.ts @@ -0,0 +1,538 @@ +import { createHash } from 'crypto'; +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { mkdir, readdir, readFile, rename, rm, stat, unlink, utimes, writeFile } from 'fs/promises'; +import path from 'path'; +import { decodeChapterTitleTag, encodeChapterFileName, encodeChapterTitleTag, ffprobeAudio } from '@/lib/server/audiobook'; + +export const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); +export const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1'); +export const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1'); + +const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations'); +const MIGRATIONS_STATE_PATH = path.join(MIGRATIONS_DIR, 'state.json'); + +type MigrationState = { + documentsV1Migrated?: boolean; + audiobooksV1Migrated?: boolean; + updatedAt?: number; +}; + +type LegacyDocumentMetadata = { + id: string; + name: string; + size: number; + lastModified: number; + type: string; +}; + +function isLegacyDocumentMetadata(value: unknown): value is LegacyDocumentMetadata { + if (!value || typeof value !== 'object') return false; + const v = value as Record<string, unknown>; + return ( + typeof v.id === 'string' && + typeof v.name === 'string' && + typeof v.size === 'number' && + typeof v.lastModified === 'number' && + typeof v.type === 'string' + ); +} + +async function loadMigrationState(): Promise<MigrationState> { + try { + return JSON.parse(await readFile(MIGRATIONS_STATE_PATH, 'utf8')) as MigrationState; + } catch { + return {}; + } +} + +async function saveMigrationState(update: Partial<MigrationState>): Promise<void> { + const state = await loadMigrationState(); + const next: MigrationState = { + documentsV1Migrated: state.documentsV1Migrated, + audiobooksV1Migrated: state.audiobooksV1Migrated, + ...update, + updatedAt: Date.now(), + }; + await mkdir(MIGRATIONS_DIR, { recursive: true }); + await writeFile(MIGRATIONS_STATE_PATH, JSON.stringify(next, null, 2)); +} + +async function hasLegacyDocumentFiles(): Promise<boolean> { + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); + } catch { + return false; + } + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.json')) continue; + + const metadataPath = path.join(DOCSTORE_DIR, entry.name); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(metadataPath, 'utf8')); + } catch { + continue; + } + if (!isLegacyDocumentMetadata(parsed)) continue; + + const contentPath = path.join(DOCSTORE_DIR, `${parsed.id}.${parsed.type}`); + if (!existsSync(contentPath)) continue; + + return true; + } + + return false; +} + +export async function isDocumentsV1Ready(): Promise<boolean> { + if (!existsSync(DOCSTORE_DIR) || !existsSync(DOCUMENTS_V1_DIR)) return false; + const state = await loadMigrationState(); + if (!state.documentsV1Migrated) return false; + if (await hasLegacyDocumentFiles()) return false; + return true; +} + +function safeDocumentName(rawName: string, fallback: string): string { + const baseName = path.basename(rawName || fallback); + return baseName.replaceAll('\u0000', '').slice(0, 240) || fallback; +} + +export async function ensureDocumentsV1Ready(): Promise<void> { + await mkdir(DOCSTORE_DIR, { recursive: true }); + await mkdir(DOCUMENTS_V1_DIR, { recursive: true }); + + const state = await loadMigrationState(); + if (state.documentsV1Migrated && !(await hasLegacyDocumentFiles())) { + return; + } + + if (!(await hasLegacyDocumentFiles())) { + await saveMigrationState({ documentsV1Migrated: true }); + return; + } + + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); + } catch { + entries = []; + } + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.json')) continue; + + const metadataPath = path.join(DOCSTORE_DIR, entry.name); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(metadataPath, 'utf8')); + } catch { + continue; + } + if (!isLegacyDocumentMetadata(parsed)) continue; + const metadata = parsed; + + const contentPath = path.join(DOCSTORE_DIR, `${metadata.id}.${metadata.type}`); + let contentStat: Awaited<ReturnType<typeof stat>>; + try { + contentStat = await stat(contentPath); + } catch { + continue; + } + if (!contentStat.isFile()) continue; + + const content = await readFile(contentPath); + const id = createHash('sha256').update(content).digest('hex'); + const fallbackName = `${id}.${metadata.type}`; + const name = safeDocumentName(metadata.name, fallbackName); + const targetFileName = `${id}__${encodeURIComponent(name)}`; + const targetPath = path.join(DOCUMENTS_V1_DIR, targetFileName); + + if (!existsSync(targetPath)) { + await writeFile(targetPath, content); + if (Number.isFinite(metadata.lastModified) && metadata.lastModified > 0) { + const stamp = new Date(metadata.lastModified); + await utimes(targetPath, stamp, stamp).catch(() => {}); + } + } + + await unlink(metadataPath).catch(() => {}); + await unlink(contentPath).catch(() => {}); + } + + await saveMigrationState({ documentsV1Migrated: !(await hasLegacyDocumentFiles()) }); +} + +async function hasLegacyAudiobookDirs(): Promise<boolean> { + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); + } catch { + return false; + } + + return entries.some((entry) => entry.isDirectory() && entry.name.endsWith('-audiobook')); +} + +async function hasLegacyAudiobookChapterLayout(): Promise<boolean> { + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); + } catch { + return false; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + + const dir = path.join(AUDIOBOOKS_V1_DIR, entry.name); + let files: string[] = []; + try { + files = await readdir(dir); + } catch { + continue; + } + + for (const file of files) { + // Per-audiobook settings file is the new format; ignore it. + if (file === 'audiobook.meta.json') continue; + + if (file.endsWith('.meta.json')) return true; + if (/^\d+-chapter\.(mp3|m4b)$/i.test(file)) return true; + if (/^[a-f0-9]{64}\.(mp3|m4b)$/i.test(file)) return true; + } + } + + return false; +} + +export async function isAudiobooksV1Ready(): Promise<boolean> { + if (!existsSync(DOCSTORE_DIR) || !existsSync(AUDIOBOOKS_V1_DIR)) return false; + const state = await loadMigrationState(); + if (!state.audiobooksV1Migrated) return false; + const legacyDirsPresent = await hasLegacyAudiobookDirs(); + const legacyChaptersPresent = await hasLegacyAudiobookChapterLayout(); + if (legacyDirsPresent || legacyChaptersPresent) return false; + return true; +} + +async function mergeDirectoryContents(sourceDir: string, targetDir: string): Promise<{ moved: number; skipped: number }> { + let moved = 0; + let skipped = 0; + + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(sourceDir, { withFileTypes: true }); + } catch { + return { moved, skipped }; + } + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + await mkdir(targetPath, { recursive: true }); + const nested = await mergeDirectoryContents(sourcePath, targetPath); + moved += nested.moved; + skipped += nested.skipped; + + try { + const remaining = await readdir(sourcePath); + if (remaining.length === 0) { + await rm(sourcePath); + } + } catch {} + continue; + } + + if (!entry.isFile()) continue; + + if (existsSync(targetPath)) { + skipped++; + continue; + } + + try { + await rename(sourcePath, targetPath); + moved++; + } catch { + skipped++; + } + } + + return { moved, skipped }; +} + +export async function ensureAudiobooksV1Ready(): Promise<void> { + await mkdir(DOCSTORE_DIR, { recursive: true }); + await mkdir(AUDIOBOOKS_V1_DIR, { recursive: true }); + + const state = await loadMigrationState(); + const legacyDirsPresent = await hasLegacyAudiobookDirs(); + const legacyChaptersPresent = await hasLegacyAudiobookChapterLayout(); + + if (state.audiobooksV1Migrated && !legacyDirsPresent && !legacyChaptersPresent) { + const stateRaw = state as unknown as Record<string, unknown>; + const allowedKeys = new Set(['documentsV1Migrated', 'audiobooksV1Migrated', 'updatedAt']); + const hasExtraKeys = Object.keys(stateRaw).some((key) => !allowedKeys.has(key)); + if (hasExtraKeys) { + await saveMigrationState({ audiobooksV1Migrated: true }); + } + return; + } + + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); + } catch { + entries = []; + } + + if (legacyDirsPresent) { + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + + const sourceDir = path.join(DOCSTORE_DIR, entry.name); + const targetDir = path.join(AUDIOBOOKS_V1_DIR, entry.name); + + try { + if (!existsSync(targetDir)) { + await rename(sourceDir, targetDir); + continue; + } + + await mkdir(targetDir, { recursive: true }); + await mergeDirectoryContents(sourceDir, targetDir); + + try { + const remaining = await readdir(sourceDir); + if (remaining.length === 0) { + await rm(sourceDir); + } else { + console.warn(`Legacy audiobook dir not fully migrated (kept): ${sourceDir}`); + } + } catch {} + } catch (error) { + console.error('Error migrating legacy audiobook directory:', error); + throw error; + } + } + } + + if (legacyDirsPresent || legacyChaptersPresent) { + await normalizeAudiobookChapterLayout(); + } + + const finalLegacyRemaining = await hasLegacyAudiobookDirs(); + const finalLegacyChaptersRemaining = await hasLegacyAudiobookChapterLayout(); + await saveMigrationState({ audiobooksV1Migrated: !finalLegacyRemaining && !finalLegacyChaptersRemaining }); +} + +type LegacyChapterMeta = { + title?: string; + duration?: number; + index?: number; + format?: string; +}; + +async function runProcess(command: string, args: string[]): Promise<void> { + return new Promise((resolve, reject) => { + const child = spawn(command, args); + let stderr = ''; + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${command} exited with code ${code}: ${stderr}`)); + }); + child.on('error', (err) => reject(err)); + }); +} + +async function rewriteAudioTitleTag(inputPath: string, outputPath: string, format: 'mp3' | 'm4b', titleTag: string): Promise<void> { + const baseArgs = ['-y', '-i', inputPath, '-metadata', `title=${titleTag}`]; + if (format === 'mp3') { + await runProcess('ffmpeg', [...baseArgs, '-c', 'copy', '-write_id3v2', '1', '-id3v2_version', '3', outputPath]); + return; + } + await runProcess('ffmpeg', [...baseArgs, '-c', 'copy', '-f', 'mp4', outputPath]); +} + +async function transcodeWithTitleTag(inputPath: string, outputPath: string, format: 'mp3' | 'm4b', titleTag: string): Promise<void> { + if (format === 'mp3') { + await runProcess('ffmpeg', [ + '-y', + '-i', + inputPath, + '-c:a', + 'libmp3lame', + '-b:a', + '64k', + '-metadata', + `title=${titleTag}`, + outputPath, + ]); + return; + } + + await runProcess('ffmpeg', [ + '-y', + '-i', + inputPath, + '-c:a', + 'aac', + '-b:a', + '64k', + '-metadata', + `title=${titleTag}`, + '-f', + 'mp4', + outputPath, + ]); +} + +async function normalizeAudiobookDirectoryChapterLayout(intermediateDir: string): Promise<void> { + let files: string[] = []; + try { + files = await readdir(intermediateDir); + } catch { + return; + } + + // Remove any combined output files from older layouts. + await unlink(path.join(intermediateDir, 'complete.mp3')).catch(() => {}); + await unlink(path.join(intermediateDir, 'complete.m4b')).catch(() => {}); + await unlink(path.join(intermediateDir, 'metadata.txt')).catch(() => {}); + await unlink(path.join(intermediateDir, 'list.txt')).catch(() => {}); + + const metaFiles = files.filter((file) => file.endsWith('.meta.json')); + const migratedIndices = new Set<number>(); + + for (const metaFile of metaFiles) { + const metaPath = path.join(intermediateDir, metaFile); + let metaRaw: unknown; + try { + metaRaw = JSON.parse(await readFile(metaPath, 'utf8')); + } catch { + continue; + } + const meta = metaRaw as LegacyChapterMeta; + const index = Number(meta.index); + if (!Number.isFinite(index) || !Number.isInteger(index) || index < 0) continue; + + const format = meta.format === 'mp3' ? 'mp3' : 'm4b'; + const sourceAudio = path.join(intermediateDir, `${index}-chapter.${format}`); + if (!existsSync(sourceAudio)) { + await unlink(metaPath).catch(() => {}); + continue; + } + + const titleTag = encodeChapterTitleTag(index, meta.title ?? `Chapter ${index + 1}`); + const taggedTemp = path.join(intermediateDir, `${index}.tagged.tmp.${format}`); + + try { + await rewriteAudioTitleTag(sourceAudio, taggedTemp, format, titleTag); + } catch { + await transcodeWithTitleTag(sourceAudio, taggedTemp, format, titleTag); + } + + const finalName = encodeChapterFileName(index, meta.title ?? `Chapter ${index + 1}`, format); + const finalPath = path.join(intermediateDir, finalName); + await unlink(finalPath).catch(() => {}); + await rename(taggedTemp, finalPath); + + await unlink(sourceAudio).catch(() => {}); + await unlink(metaPath).catch(() => {}); + migratedIndices.add(index); + } + + // Migrate any remaining legacy chapter files without metadata. + files = await readdir(intermediateDir).catch(() => []); + for (const file of files) { + const match = /^(\d+)-chapter\.(mp3|m4b)$/i.exec(file); + if (!match) continue; + const index = Number(match[1]); + if (!Number.isInteger(index) || index < 0) continue; + if (migratedIndices.has(index)) continue; + + const format = match[2].toLowerCase() as 'mp3' | 'm4b'; + const sourceAudio = path.join(intermediateDir, file); + const titleTag = encodeChapterTitleTag(index, `Chapter ${index + 1}`); + const taggedTemp = path.join(intermediateDir, `${index}.tagged.tmp.${format}`); + + try { + await rewriteAudioTitleTag(sourceAudio, taggedTemp, format, titleTag); + } catch { + await transcodeWithTitleTag(sourceAudio, taggedTemp, format, titleTag); + } + + const finalName = encodeChapterFileName(index, `Chapter ${index + 1}`, format); + const finalPath = path.join(intermediateDir, finalName); + await unlink(finalPath).catch(() => {}); + await rename(taggedTemp, finalPath); + + await unlink(sourceAudio).catch(() => {}); + } + + // Rename any sha-named chapter files from previous runs into the index__title scheme. + files = await readdir(intermediateDir).catch(() => []); + const shaCandidates = files.filter((file) => /^[a-f0-9]{64}\.(mp3|m4b)$/i.test(file)); + for (const file of shaCandidates) { + const sourceAudio = path.join(intermediateDir, file); + const format = file.toLowerCase().endsWith('.mp3') ? 'mp3' : 'm4b'; + + let decoded: { index: number; title: string } | null = null; + try { + const probe = await ffprobeAudio(sourceAudio); + decoded = probe.titleTag ? decodeChapterTitleTag(probe.titleTag) : null; + } catch { + decoded = null; + } + if (!decoded) continue; + + const finalName = encodeChapterFileName(decoded.index, decoded.title, format); + const finalPath = path.join(intermediateDir, finalName); + await unlink(finalPath).catch(() => {}); + await rename(sourceAudio, finalPath).catch(() => {}); + } + + // Remove any leftover input temp files. + files = await readdir(intermediateDir).catch(() => []); + for (const file of files) { + if (/^\d+-input\.mp3$/i.test(file)) { + await unlink(path.join(intermediateDir, file)).catch(() => {}); + } + if (file.endsWith('.meta.json')) { + await unlink(path.join(intermediateDir, file)).catch(() => {}); + } + } +} + +async function normalizeAudiobookChapterLayout(): Promise<void> { + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + const dir = path.join(AUDIOBOOKS_V1_DIR, entry.name); + try { + await normalizeAudiobookDirectoryChapterLayout(dir); + } catch (error) { + console.error('Error migrating audiobook chapter layout:', error); + throw error; + } + } +} diff --git a/src/lib/server/library.ts b/src/lib/server/library.ts new file mode 100644 index 0000000..25cf35b --- /dev/null +++ b/src/lib/server/library.ts @@ -0,0 +1,48 @@ +import path from 'path'; + +export const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); +export const DEFAULT_LIBRARY_DIR = path.join(DOCSTORE_DIR, 'library'); + +export function parseLibraryRoots(): string[] { + const raw = process.env.OPENREADER_LIBRARY_DIRS ?? process.env.OPENREADER_LIBRARY_DIR ?? ''; + + const roots = raw + .split(/[,:;]/g) + .map((value) => value.trim()) + .filter(Boolean); + + if (roots.length > 0) { + return roots; + } + + return [DEFAULT_LIBRARY_DIR]; +} + +export function contentTypeForName(name: string): string { + const ext = path.extname(name).toLowerCase(); + if (ext === '.pdf') return 'application/pdf'; + if (ext === '.epub') return 'application/epub+zip'; + if (ext === '.md' || ext === '.mdown' || ext === '.markdown') return 'text/markdown; charset=utf-8'; + if (ext === '.html' || ext === '.htm') return 'text/html; charset=utf-8'; + return 'text/plain; charset=utf-8'; +} + +export function decodeLibraryId(id: string): { rootIndex: number; relativePath: string } | null { + try { + const decoded = Buffer.from(id, 'base64url').toString('utf8'); + const sepIndex = decoded.indexOf(':'); + if (sepIndex <= 0) return null; + const rootIndex = Number(decoded.slice(0, sepIndex)); + if (!Number.isInteger(rootIndex) || rootIndex < 0) return null; + const relativePath = decoded.slice(sepIndex + 1); + if (!relativePath) return null; + return { rootIndex, relativePath }; + } catch { + return null; + } +} + +export function isPathWithinRoot(resolvedRoot: string, resolvedFile: string): boolean { + return resolvedFile === resolvedRoot || resolvedFile.startsWith(resolvedRoot + path.sep); +} + diff --git a/src/lib/sha256.ts b/src/lib/sha256.ts new file mode 100644 index 0000000..d36f055 --- /dev/null +++ b/src/lib/sha256.ts @@ -0,0 +1,158 @@ +'use client'; + +function toHex(bytes: Uint8Array): string { + let hex = ''; + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, '0'); + } + return hex; +} + +function rotr32(x: number, n: number): number { + return ((x >>> n) | (x << (32 - n))) >>> 0; +} + +function sha256HexSoftwareFromBytes(bytes: Uint8Array): string { + const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]); + + let h0 = 0x6a09e667; + let h1 = 0xbb67ae85; + let h2 = 0x3c6ef372; + let h3 = 0xa54ff53a; + let h4 = 0x510e527f; + let h5 = 0x9b05688c; + let h6 = 0x1f83d9ab; + let h7 = 0x5be0cd19; + + const w = new Uint32Array(64); + + const compress = (block: Uint8Array, offset: number) => { + for (let i = 0; i < 16; i++) { + const j = offset + i * 4; + w[i] = ((block[j]! << 24) | (block[j + 1]! << 16) | (block[j + 2]! << 8) | block[j + 3]!) >>> 0; + } + for (let i = 16; i < 64; i++) { + const s0 = (rotr32(w[i - 15]!, 7) ^ rotr32(w[i - 15]!, 18) ^ (w[i - 15]! >>> 3)) >>> 0; + const s1 = (rotr32(w[i - 2]!, 17) ^ rotr32(w[i - 2]!, 19) ^ (w[i - 2]! >>> 10)) >>> 0; + w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0; + } + + let a = h0; + let b = h1; + let c = h2; + let d = h3; + let e = h4; + let f = h5; + let g = h6; + let h = h7; + + for (let i = 0; i < 64; i++) { + const S1 = (rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25)) >>> 0; + const ch = ((e & f) ^ (~e & g)) >>> 0; + const temp1 = (h + S1 + ch + K[i]! + w[i]!) >>> 0; + const S0 = (rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22)) >>> 0; + const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0; + const temp2 = (S0 + maj) >>> 0; + + h = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + h5 = (h5 + f) >>> 0; + h6 = (h6 + g) >>> 0; + h7 = (h7 + h) >>> 0; + }; + + let offset = 0; + for (; offset + 64 <= bytes.length; offset += 64) { + compress(bytes, offset); + } + + const remainder = bytes.length - offset; + const tail = new Uint8Array(128); + tail.set(bytes.subarray(offset)); + tail[remainder] = 0x80; + + const bitLenHi = (bytes.length >>> 29) >>> 0; + const bitLenLo = (bytes.length << 3) >>> 0; + + const totalTailLen = remainder + 1 + 8 <= 64 ? 64 : 128; + const lenOffset = totalTailLen - 8; + tail[lenOffset] = (bitLenHi >>> 24) & 0xff; + tail[lenOffset + 1] = (bitLenHi >>> 16) & 0xff; + tail[lenOffset + 2] = (bitLenHi >>> 8) & 0xff; + tail[lenOffset + 3] = bitLenHi & 0xff; + tail[lenOffset + 4] = (bitLenLo >>> 24) & 0xff; + tail[lenOffset + 5] = (bitLenLo >>> 16) & 0xff; + tail[lenOffset + 6] = (bitLenLo >>> 8) & 0xff; + tail[lenOffset + 7] = bitLenLo & 0xff; + + compress(tail, 0); + if (totalTailLen === 128) { + compress(tail, 64); + } + + return ( + h0.toString(16).padStart(8, '0') + + h1.toString(16).padStart(8, '0') + + h2.toString(16).padStart(8, '0') + + h3.toString(16).padStart(8, '0') + + h4.toString(16).padStart(8, '0') + + h5.toString(16).padStart(8, '0') + + h6.toString(16).padStart(8, '0') + + h7.toString(16).padStart(8, '0') + ); +} + +let didWarnWebCryptoUnavailable = false; +let didWarnWebCryptoDigestFailed = false; + +export async function sha256HexFromBytes(bytes: Uint8Array): Promise<string> { + const subtle = globalThis.crypto?.subtle; + if (!subtle) { + if (!didWarnWebCryptoUnavailable) { + didWarnWebCryptoUnavailable = true; + console.warn('[sha256] WebCrypto unavailable; falling back to software SHA-256.'); + } + return sha256HexSoftwareFromBytes(bytes); + } + + try { + const digest = await subtle.digest('SHA-256', bytes as unknown as BufferSource); + return toHex(new Uint8Array(digest)); + } catch (error) { + if (!didWarnWebCryptoDigestFailed) { + didWarnWebCryptoDigestFailed = true; + console.warn('[sha256] WebCrypto digest failed; falling back to software SHA-256.', error); + } + return sha256HexSoftwareFromBytes(bytes); + } +} + +export async function sha256HexFromArrayBuffer(buffer: ArrayBuffer): Promise<string> { + return sha256HexFromBytes(new Uint8Array(buffer)); +} + +export async function sha256HexFromString(text: string): Promise<string> { + return sha256HexFromBytes(new TextEncoder().encode(text)); +} diff --git a/src/types/client.ts b/src/types/client.ts index 113b6ad..32a3b36 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -38,6 +38,16 @@ export interface AudiobookStatusResponse { chapters: TTSAudiobookChapter[]; bookId: string | null; hasComplete: boolean; + settings?: AudiobookGenerationSettings | null; +} + +export interface AudiobookGenerationSettings { + ttsProvider: string; + ttsModel: string; + voice: string; + nativeSpeed: number; + postSpeed: number; + format: TTSAudiobookFormat; } export interface CreateChapterPayload { @@ -46,6 +56,7 @@ export interface CreateChapterPayload { bookId: string; format: TTSAudiobookFormat; chapterIndex: number; + settings?: AudiobookGenerationSettings; } diff --git a/src/types/documents.ts b/src/types/documents.ts index c93b615..e1307f3 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -65,3 +65,8 @@ export interface DocumentListState { showHint: boolean; viewMode?: 'list' | 'grid'; } + +export interface LibraryDocument extends BaseDocument { + // `id` is a stable server-provided reference, not necessarily the same as the local document id. + id: string; +} diff --git a/tests/export.spec.ts b/tests/export.spec.ts index 77324a7..83fc6df 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -73,6 +73,11 @@ async function expectChaptersBackendState(page: Page, bookId: string) { return json; } +async function resetAudiobookById(page: Page, bookId: string) { + const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`); + expect(res.ok()).toBeTruthy(); +} + async function resetAudiobookIfPresent(page: Page) { const resetButtons = page.getByRole('button', { name: 'Reset' }); const count = await resetButtons.count(); @@ -88,9 +93,7 @@ async function resetAudiobookIfPresent(page: Page) { const confirmReset = page.getByRole('button', { name: 'Reset' }).last(); await confirmReset.click(); - await expect( - page.getByText(/Generation will use current TTS playback options./i) - ).toBeVisible({ timeout: 60_000 }); + await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); } test.describe('Audiobook export', () => { @@ -105,6 +108,7 @@ test.describe('Audiobook export', () => { // Capture the generated document/book id from the /pdf/[id] URL const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); // Open the audiobook export modal from the header button await openExportModal(page); @@ -155,6 +159,7 @@ test.describe('Audiobook export', () => { // URL should now be /epub/[id] const bookId = await getBookIdFromUrl(page, 'epub'); + await resetAudiobookById(page, bookId); // Open the audiobook export modal from the header button await openExportModal(page); @@ -220,6 +225,7 @@ test.describe('Audiobook export', () => { await uploadAndDisplay(page, 'sample.pdf'); const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); await openExportModal(page); await setContainerFormatToMP3(page); @@ -247,6 +253,7 @@ test.describe('Audiobook export', () => { await uploadAndDisplay(page, 'sample.pdf'); const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); await openExportModal(page); await setContainerFormatToMP3(page); @@ -265,10 +272,8 @@ test.describe('Audiobook export', () => { const confirmReset = page.getByRole('button', { name: 'Reset' }).last(); await confirmReset.click(); - // After reset, the hint text for starting generation should re-appear - await expect( - page.getByText(/Generation will use current TTS playback options./i) - ).toBeVisible({ timeout: 60_000 }); + // After reset, generation should be startable again + await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); // Backend should report no existing chapters for this bookId const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`); @@ -285,6 +290,7 @@ test.describe('Audiobook export', () => { // Extract bookId from /pdf/[id] URL (for backend verification later) const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); // Open Export Audiobook modal await openExportModal(page); diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts index dc56f38..7634396 100644 --- a/tests/upload.spec.ts +++ b/tests/upload.spec.ts @@ -21,6 +21,45 @@ test.describe('Document Upload Tests', () => { await expectDocumentListed(page, 'sample.txt'); }); + test('hashes text/HTML docs using UTF-8 encoded stored string', async ({ page }) => { + await uploadFile(page, 'sample.txt'); + await expectDocumentListed(page, 'sample.txt'); + + const result = await page.evaluate(async () => { + const idb = await new Promise<IDBDatabase>((resolve, reject) => { + const request = indexedDB.open('openreader-db'); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + + try { + const docs = await new Promise<any[]>((resolve, reject) => { + const tx = idb.transaction('html-documents', 'readonly'); + const store = tx.objectStore('html-documents'); + const request = store.getAll(); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result as any[]); + }); + + if (!docs[0]?.data || !docs[0]?.id) { + return { ok: false, reason: 'Missing stored html document' as const }; + } + + const bytes = new TextEncoder().encode(String(docs[0].data)); + const digest = await crypto.subtle.digest('SHA-256', bytes); + const computedId = Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + return { ok: computedId === docs[0].id, storedId: docs[0].id as string, computedId }; + } finally { + idb.close(); + } + }); + + expect(result.ok, `Expected storedId=${(result as any).storedId} computedId=${(result as any).computedId}`).toBeTruthy(); + }); + test('uploads and converts a DOCX document', async ({ page }) => { // This test only runs in development mode test.skip(process.env.NODE_ENV === 'production', 'DOCX upload is only available in development mode'); From 9c56914542391e342ad791b5efda0f75ed097db3 Mon Sep 17 00:00:00 2001 From: Richard R <me@richardr.dev> Date: Mon, 19 Jan 2026 14:24:03 -0700 Subject: [PATCH 02/12] chore: bump version to v1.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f83d376..1053997 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openreader-webui", - "version": "v1.1.2", + "version": "v1.2.0", "private": true, "scripts": { "dev": "next dev --turbopack -p 3003", From 57ac17fb341a062f42775b59a760a54cbba612fb Mon Sep 17 00:00:00 2001 From: Richard R <me@richardr.dev> Date: Mon, 19 Jan 2026 14:33:49 -0700 Subject: [PATCH 03/12] refactor(api): force dynamic rendering for API routes Add 'force-dynamic' export to audiobook and documents API routes to ensure dynamic rendering. Remove 'nodejs' runtime export from TTS route for consistency. --- src/app/api/audiobook/chapter/route.ts | 2 ++ src/app/api/audiobook/route.ts | 2 ++ src/app/api/audiobook/status/route.ts | 2 ++ src/app/api/documents/route.ts | 2 ++ src/app/api/tts/route.ts | 2 -- 5 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 1500efc..2c1e6ce 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -5,6 +5,8 @@ import { join } from 'path'; import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; import { findStoredChapterByIndex } from '@/lib/server/audiobook'; +export const dynamic = 'force-dynamic'; + function getAudiobooksRootDir(request: NextRequest): string { const raw = request.headers.get('x-openreader-test-namespace')?.trim(); if (!raw) return AUDIOBOOKS_V1_DIR; diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 5e30469..6042fde 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -9,6 +9,8 @@ import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffpro import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; +export const dynamic = 'force-dynamic'; + function getAudiobooksRootDir(request: NextRequest): string { const raw = request.headers.get('x-openreader-test-namespace')?.trim(); if (!raw) return AUDIOBOOKS_V1_DIR; diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index f279372..e2adff5 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -7,6 +7,8 @@ import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts'; import { readFile } from 'fs/promises'; +export const dynamic = 'force-dynamic'; + function getAudiobooksRootDir(request: NextRequest): string { const raw = request.headers.get('x-openreader-test-namespace')?.trim(); if (!raw) return AUDIOBOOKS_V1_DIR; diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index c822058..08ba8e5 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -5,6 +5,8 @@ import path from 'path'; import { DOCUMENTS_V1_DIR, isDocumentsV1Ready } from '@/lib/server/docstore'; import type { BaseDocument, DocumentType, SyncedDocument } from '@/types/documents'; +export const dynamic = 'force-dynamic'; + const SYNC_DIR = DOCUMENTS_V1_DIR; async function trySetFileMtime(filePath: string, lastModifiedMs: number): Promise<void> { diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index ff3680b..6195d32 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -7,8 +7,6 @@ import { createHash } from 'crypto'; import type { TTSRequestPayload } from '@/types/client'; import type { TTSError, TTSAudioBuffer } from '@/types/tts'; -export const runtime = 'nodejs'; - type CustomVoice = string; type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & { voice: SpeechCreateParams['voice'] | CustomVoice; From 53b225b976b0dc3fab094f3609a9512778f2dc57 Mon Sep 17 00:00:00 2001 From: Richard R <me@richardr.dev> Date: Mon, 19 Jan 2026 14:45:11 -0700 Subject: [PATCH 04/12] test: remove audiobook status API test --- tests/api.spec.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/api.spec.ts b/tests/api.spec.ts index dde7756..d7a7c61 100644 --- a/tests/api.spec.ts +++ b/tests/api.spec.ts @@ -8,13 +8,4 @@ test.describe('API health checks', () => { expect(Array.isArray(json.voices)).toBeTruthy(); expect(json.voices.length).toBeGreaterThan(0); }); - - test('GET /api/audiobook/status returns 200 with exists flag and chapters array', async ({ request }) => { - const bookId = `healthcheck-${Date.now()}`; - const res = await request.get(`/api/audiobook/status?bookId=${bookId}`); - expect(res.ok()).toBeTruthy(); - const json = await res.json(); - expect(json).toHaveProperty('exists'); - expect(Array.isArray(json.chapters)).toBeTruthy(); - }); }); \ No newline at end of file From f11c015f7e03c62c794aebae5098f059d9ced073 Mon Sep 17 00:00:00 2001 From: Richard R <me@richardr.dev> Date: Mon, 19 Jan 2026 15:02:35 -0700 Subject: [PATCH 05/12] docs: Refactor README to remove `*(New)*` labels, introduce server-side sync and external library import, and reorder feature descriptions. --- README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c2127a6..748cd71 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,21 @@ 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) -- 🎯 *(New)* **Multi-Provider TTS Support** +- 🎯 **Multi-Provider TTS Support** - [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI): Supporting multi-voice combinations (like `af_heart+af_bella`) - [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI) - **Custom OpenAI-compatible**: Any TTS API with `/v1/audio/voices` and `/v1/audio/speech` endpoints - **Cloud TTS Providers (requiring API keys)** - [**Deepinfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M + models with support for cloned voices and more - [**OpenAI API ($$)**](https://platform.openai.com/docs/pricing#transcription-and-speech): tts-1, tts-1-hd, and gpt-4o-mini-tts w/ instructions -- 📖 *(Updated)* **Read Along Experience** providing real-time text highlighting during playback (PDF/EPUB) +- 🛜 *(Updated)* **Server-side Sync and Storage** + - *(New)* **External Library Import** enables importing documents to the browser's storage from a folder mounted on the server + - *(Updated)* **Sync documents** between the browser and server to get them on other browsers or devices +- 🎧 **Server-side Audiobook Export** in **m4b/mp3**, with resumable, chapter-based export and regeneration +- 📖 **Read Along Experience** providing real-time text highlighting during playback (PDF/EPUB) - *(New)* **Word-by-word** highlighting uses word-by-word timestamps generated server-side with [*whisper.cpp*](https://github.com/ggml-org/whisper.cpp) (optional) -- 🧠 *(New)* **Smart Sentence-Aware Narration** merges sentences across pages/chapters for smoother TTS -- 🎧 *(New)* **Reliable Audiobook Export** in **m4b/mp3**, with resumable, chapter-based export and regeneration -- 🚀 *(New)* **Optimized Next.js TTS Proxy** with audio caching and optimized repeat playback -- 💾 **Local-First Architecture** stores documents and more in-browser with Dexie.js -- 🛜 **Optional Server-side documents** using backend `/docstore` for all users +- 🧠 **Smart Sentence-Aware Narration** merges sentences across pages/chapters for smoother TTS +- 🚀 **Optimized Next.js TTS Proxy** with audio caching and optimized repeat playback - 🎨 **Customizable Experience** - 🎨 Multiple app theme options - ⚙️ Various TTS and document handling settings From 741126c0b436d4448712e4c621801c086b6af672 Mon Sep 17 00:00:00 2001 From: Richard R <me@richardr.dev> Date: Mon, 19 Jan 2026 15:27:07 -0700 Subject: [PATCH 06/12] feat: Display a toast notification after library migration, powered by new migration status in the API response. --- src/app/api/migrations/v1/route.ts | 6 ++++-- src/contexts/ConfigContext.tsx | 24 ++++++++++++++++++++++-- src/lib/server/docstore.ts | 12 +++++++----- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/app/api/migrations/v1/route.ts b/src/app/api/migrations/v1/route.ts index 0ea512a..0cca347 100644 --- a/src/app/api/migrations/v1/route.ts +++ b/src/app/api/migrations/v1/route.ts @@ -114,8 +114,8 @@ export async function POST(request: NextRequest) { } } - await ensureDocumentsV1Ready(); - await ensureAudiobooksV1Ready(); + const documentsMigrated = await ensureDocumentsV1Ready(); + const audiobooksMigrated = await ensureAudiobooksV1Ready(); const rekey = await rekeyAudiobooksV1(mappings); const documentsReady = await isDocumentsV1Ready(); @@ -125,6 +125,8 @@ export async function POST(request: NextRequest) { success: true, documentsReady, audiobooksReady, + documentsMigrated, + audiobooksMigrated, rekey, }); } catch (error) { diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 9b8a57e..f8443a2 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -103,11 +103,31 @@ export function ConfigProvider({ children }: { children: ReactNode }) { // Run server-side v1 migrations proactively, since the client may now // reference SHA-based IDs immediately after the Dexie migration. - await fetch('/api/migrations/v1', { + const response = await fetch('/api/migrations/v1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mappings }), - }).catch(() => undefined); + }).catch(() => null); + + if (response?.ok) { + const data = await response.json(); + const didMigrate = + data.documentsMigrated || + data.audiobooksMigrated || + (data.rekey?.renamed ?? 0) > 0 || + (data.rekey?.merged ?? 0) > 0; + + if (didMigrate) { + toast.success('Library migration complete', { + duration: 5000, + icon: '📦', + style: { + background: 'var(--offbase)', + color: 'var(--foreground)', + }, + }); + } + } } catch (error) { console.warn('Startup migrations failed:', error); } diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts index b6dd3da..07ac725 100644 --- a/src/lib/server/docstore.ts +++ b/src/lib/server/docstore.ts @@ -101,18 +101,18 @@ function safeDocumentName(rawName: string, fallback: string): string { return baseName.replaceAll('\u0000', '').slice(0, 240) || fallback; } -export async function ensureDocumentsV1Ready(): Promise<void> { +export async function ensureDocumentsV1Ready(): Promise<boolean> { await mkdir(DOCSTORE_DIR, { recursive: true }); await mkdir(DOCUMENTS_V1_DIR, { recursive: true }); const state = await loadMigrationState(); if (state.documentsV1Migrated && !(await hasLegacyDocumentFiles())) { - return; + return false; } if (!(await hasLegacyDocumentFiles())) { await saveMigrationState({ documentsV1Migrated: true }); - return; + return false; } let entries: Array<import('fs').Dirent> = []; @@ -165,6 +165,7 @@ export async function ensureDocumentsV1Ready(): Promise<void> { } await saveMigrationState({ documentsV1Migrated: !(await hasLegacyDocumentFiles()) }); + return true; } async function hasLegacyAudiobookDirs(): Promise<boolean> { @@ -269,7 +270,7 @@ async function mergeDirectoryContents(sourceDir: string, targetDir: string): Pro return { moved, skipped }; } -export async function ensureAudiobooksV1Ready(): Promise<void> { +export async function ensureAudiobooksV1Ready(): Promise<boolean> { await mkdir(DOCSTORE_DIR, { recursive: true }); await mkdir(AUDIOBOOKS_V1_DIR, { recursive: true }); @@ -284,7 +285,7 @@ export async function ensureAudiobooksV1Ready(): Promise<void> { if (hasExtraKeys) { await saveMigrationState({ audiobooksV1Migrated: true }); } - return; + return false; } let entries: Array<import('fs').Dirent> = []; @@ -333,6 +334,7 @@ export async function ensureAudiobooksV1Ready(): Promise<void> { const finalLegacyRemaining = await hasLegacyAudiobookDirs(); const finalLegacyChaptersRemaining = await hasLegacyAudiobookChapterLayout(); await saveMigrationState({ audiobooksV1Migrated: !finalLegacyRemaining && !finalLegacyChaptersRemaining }); + return true; } type LegacyChapterMeta = { From f947ece01a832ae0b6d326104b956a1642afbbfa Mon Sep 17 00:00:00 2001 From: Richard R <me@richardr.dev> Date: Mon, 19 Jan 2026 15:53:29 -0700 Subject: [PATCH 07/12] feat: Implement document selection modal to allow users to select specific documents for sync, load, and import operations. --- src/app/api/documents/route.ts | 22 +- src/components/DocumentSelectionModal.tsx | 255 ++++++++++++++++++++++ src/components/SettingsModal.tsx | 218 +++++++++++------- src/lib/dexie.ts | 170 ++++++++++++--- 4 files changed, 551 insertions(+), 114 deletions(-) create mode 100644 src/components/DocumentSelectionModal.tsx diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 08ba8e5..fdb98a9 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -39,7 +39,7 @@ function parseSyncedFileName(fileName: string): { id: string; name: string } | n } } -async function loadSyncedDocuments(includeData: boolean): Promise<(BaseDocument | SyncedDocument)[]> { +async function loadSyncedDocuments(includeData: boolean, targetIds?: Set<string>): Promise<(BaseDocument | SyncedDocument)[]> { const results: (BaseDocument | SyncedDocument)[] = []; let files: string[] = []; @@ -53,6 +53,9 @@ async function loadSyncedDocuments(includeData: boolean): Promise<(BaseDocument const parsed = parseSyncedFileName(file); if (!parsed) continue; + // Filter by ID if specific IDs are requested + if (targetIds && !targetIds.has(parsed.id)) continue; + const filePath = path.join(SYNC_DIR, file); let fileStat: Awaited<ReturnType<typeof stat>>; try { @@ -154,10 +157,21 @@ export async function GET(req: NextRequest) { } const url = new URL(req.url); - const format = url.searchParams.get('format') ?? 'sync'; - const includeData = format !== 'metadata'; + const list = url.searchParams.get('list') === 'true'; + const format = url.searchParams.get('format'); + const idsParam = url.searchParams.get('ids'); - const documents = await loadSyncedDocuments(includeData); + // If list=true, force metadata only. + // If format=metadata, force metadata only. + // Otherwise include data. + const includeData = !list && format !== 'metadata'; + + let targetIds: Set<string> | undefined; + if (idsParam) { + targetIds = new Set(idsParam.split(',').filter(Boolean)); + } + + const documents = await loadSyncedDocuments(includeData, targetIds); return NextResponse.json({ documents }); } catch (error) { diff --git a/src/components/DocumentSelectionModal.tsx b/src/components/DocumentSelectionModal.tsx new file mode 100644 index 0000000..e03af18 --- /dev/null +++ b/src/components/DocumentSelectionModal.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; +import { Fragment, useEffect, useState } from 'react'; +import { BaseDocument } from '@/types/documents'; + +interface DocumentSelectionModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (selectedFiles: BaseDocument[]) => void; + title: string; + confirmLabel: string; + isProcessing: boolean; + defaultSelected?: boolean; + /** + * Data source: + * 1. `initialFiles`: Pass local files directly (synchronous). + * 2. `fetcher`: Pass an async function to load files (e.g. from server). + */ + initialFiles?: BaseDocument[]; + fetcher?: () => Promise<BaseDocument[]>; +} + +export function DocumentSelectionModal({ + isOpen, + onClose, + onConfirm, + title, + confirmLabel, + isProcessing, + defaultSelected = false, + initialFiles, + fetcher, +}: DocumentSelectionModalProps) { + const [files, setFiles] = useState<BaseDocument[]>([]); + const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); + const [isLoading, setIsLoading] = useState(false); + const [lastSelectedId, setLastSelectedId] = useState<string | null>(null); + + useEffect(() => { + if (isOpen) { + if (initialFiles) { + setFiles(initialFiles); + if (defaultSelected) { + setSelectedIds(new Set(initialFiles.map((f) => f.id))); + } else { + setSelectedIds(new Set()); + } + setLastSelectedId(null); + } else if (fetcher) { + setIsLoading(true); + fetcher() + .then((data) => { + setFiles(data); + if (defaultSelected) { + setSelectedIds(new Set(data.map((f) => f.id))); + } else { + setSelectedIds(new Set()); + } + setLastSelectedId(null); + }) + .catch((err) => console.error('Failed to load documents:', err)) + .finally(() => setIsLoading(false)); + } else { + setFiles([]); + setSelectedIds(new Set()); + } + } + }, [isOpen, initialFiles, fetcher, defaultSelected]); + + const toggleSelection = (id: string, multiSelect: boolean, rangeSelect: boolean) => { + const newSelected = new Set(multiSelect ? selectedIds : []); + + if (rangeSelect && lastSelectedId && files.some((f) => f.id === lastSelectedId)) { + const lastIndex = files.findIndex((f) => f.id === lastSelectedId); + const currentIndex = files.findIndex((f) => f.id === id); + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + + for (let i = start; i <= end; i++) { + newSelected.add(files[i].id); + } + } else { + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + } + + setSelectedIds(newSelected); + setLastSelectedId(id); + }; + + const handleRowClick = (e: React.MouseEvent, id: string) => { + if (e.metaKey || e.ctrlKey) { + toggleSelection(id, true, false); + } else if (e.shiftKey) { + toggleSelection(id, true, true); + } else { + // "Finder" behavior: Click selects only this one (unless multiselect modifier used) + // Checkbox click is handled separately to allow toggling without clearing + const newSelected = new Set<string>(); + newSelected.add(id); + setSelectedIds(newSelected); + setLastSelectedId(id); + } + }; + + const handleCheckboxChange = (id: string, checked: boolean) => { + const newSelected = new Set(selectedIds); + if (checked) newSelected.add(id); + else newSelected.delete(id); + setSelectedIds(newSelected); + setLastSelectedId(id); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedIds(new Set(files.map(f => f.id))); + } else { + setSelectedIds(new Set()); + } + }; + + const selectedCount = selectedIds.size; + const allSelected = files.length > 0 && selectedCount === files.length; + const isIndeterminate = selectedCount > 0 && selectedCount < files.length; + + const handleConfirmClick = () => { + const selectedFiles = files.filter((f) => selectedIds.has(f.id)); + onConfirm(selectedFiles); + }; + + const formatSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + return ( + <Transition appear show={isOpen} as={Fragment}> + <Dialog as="div" className="relative z-[60]" onClose={onClose}> + <TransitionChild + as={Fragment} + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <div className="fixed inset-0 overlay-dim backdrop-blur-sm" /> + </TransitionChild> + + <div className="fixed inset-0 overflow-y-auto"> + <div className="flex min-h-full items-center justify-center p-4 text-center"> + <TransitionChild + as={Fragment} + enter="ease-out duration-300" + enterFrom="opacity-0 scale-95" + enterTo="opacity-100 scale-100" + leave="ease-in duration-200" + leaveFrom="opacity-100 scale-100" + leaveTo="opacity-0 scale-95" + > + <DialogPanel className="w-full max-w-2xl transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all flex flex-col h-[80vh]"> + <DialogTitle + as="h3" + className="text-lg font-semibold leading-6 text-foreground mb-4 flex-shrink-0 flex justify-between items-center" + > + {title} + {files.length > 0 && ( + <div className="flex items-center text-sm font-normal"> + <label className="flex items-center gap-2 cursor-pointer select-none text-muted hover:text-foreground transition-colors"> + <input + type="checkbox" + className="rounded border-muted text-accent focus:ring-accent" + checked={allSelected} + ref={input => { + if (input) input.indeterminate = isIndeterminate; + }} + onChange={(e) => handleSelectAll(e.target.checked)} + /> + Select All + </label> + </div> + )} + </DialogTitle> + + <div className="flex-1 overflow-auto border border-offbase rounded-lg bg-background p-2 min-h-0"> + {isLoading ? ( + <div className="flex items-center justify-center h-full text-muted">Loading documents...</div> + ) : files.length === 0 ? ( + <div className="flex items-center justify-center h-full text-muted">No documents found.</div> + ) : ( + <div className="space-y-0.5"> + {files.map((file) => { + const isSelected = selectedIds.has(file.id); + return ( + <div + key={file.id} + onClick={(e) => handleRowClick(e, file.id)} + className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none + ${isSelected ? 'bg-accent/10' : 'hover:bg-offbase'} + `} + > + <div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}> + <input + type="checkbox" + checked={isSelected} + onChange={(e) => handleCheckboxChange(file.id, e.target.checked)} + className="rounded border-muted text-accent focus:ring-accent" + /> + </div> + <div + className={`flex-1 truncate ${ + isSelected ? 'text-accent font-medium' : 'text-foreground' + }`} + > + {file.name} + </div> + <div className="text-muted text-xs whitespace-nowrap">{formatSize(file.size)}</div> + </div> + ); + })} + </div> + )} + </div> + + <div className="mt-4 flex justify-end gap-3 flex-shrink-0"> + <button + type="button" + className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" + onClick={onClose} + > + Cancel + </button> + <button + type="button" + className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm font-medium text-background hover:bg-secondary-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed" + onClick={handleConfirmClick} + disabled={selectedCount === 0 || isProcessing} + > + {isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`} + </button> + </div> + </DialogPanel> + </TransitionChild> + </div> + </div> + </Dialog> + </Transition> + ); +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index cbbba06..dc25e1f 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -22,13 +22,15 @@ import { import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; -import { syncDocumentsToServer, loadDocumentsFromServer, importDocumentsFromLibrary, getFirstVisit, setFirstVisit } from '@/lib/dexie'; +import { syncSelectedDocumentsToServer, loadSelectedDocumentsFromServer, importSelectedDocuments, getFirstVisit, setFirstVisit, getAllPdfDocuments, getAllEpubDocuments, getAllHtmlDocuments } from '@/lib/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { THEMES } from '@/contexts/ThemeContext'; import { deleteServerDocuments } from '@/lib/client'; +import { DocumentSelectionModal } from '@/components/DocumentSelectionModal'; +import { BaseDocument } from '@/types/documents'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -52,6 +54,21 @@ export function SettingsModal() { const [isSyncing, setIsSyncing] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isImportingLibrary, setIsImportingLibrary] = useState(false); + const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false); + const [selectionModalProps, setSelectionModalProps] = useState<{ + title: string; + confirmLabel: string; + mode: 'library' | 'load' | 'save'; + defaultSelected: boolean; + initialFiles?: BaseDocument[]; + fetcher?: () => Promise<BaseDocument[]>; + }>({ + title: '', + confirmLabel: '', + mode: 'library', + defaultSelected: false + }); + const [showProgress, setShowProgress] = useState(false); const [statusMessage, setStatusMessage] = useState(''); const [operationType, setOperationType] = useState<'sync' | 'load' | 'library'>('sync'); @@ -150,100 +167,126 @@ export function SettingsModal() { }, [modelValue, ttsModels]); const handleSync = async () => { - const controller = new AbortController(); - setAbortController(controller); + // Collect local documents + const pdfs = await getAllPdfDocuments(); + const epubs = await getAllEpubDocuments(); + const htmls = await getAllHtmlDocuments(); - try { - setIsSyncing(true); - setShowProgress(true); - setProgress(0); - setOperationType('sync'); - setStatusMessage('Preparing documents...'); - await syncDocumentsToServer((progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); - } catch (error) { - if (controller.signal.aborted) { - console.log('Sync operation cancelled'); - setStatusMessage('Operation cancelled'); - } else { - console.error('Sync failed:', error); - setStatusMessage('Sync failed. Please try again.'); - } - } finally { - setIsSyncing(false); - setShowProgress(false); - setProgress(0); - setStatusMessage(''); - setAbortController(null); - } + const allDocs: BaseDocument[] = [ + ...pdfs.map(d => ({ ...d, type: 'pdf' as const })), + ...epubs.map(d => ({ ...d, type: 'epub' as const })), + ...htmls.map(d => ({ ...d, type: 'html' as const })) + ]; + + setSelectionModalProps({ + title: 'Save to Server', + confirmLabel: 'Save', + mode: 'save', + defaultSelected: true, + initialFiles: allDocs + }); + setIsSelectionModalOpen(true); }; const handleLoad = async () => { - const controller = new AbortController(); - setAbortController(controller); - - try { - setIsLoading(true); - setShowProgress(true); - setProgress(0); - setOperationType('load'); - setStatusMessage('Downloading documents from server...'); - await loadDocumentsFromServer((progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); - if (controller.signal.aborted) return; - setStatusMessage('Documents loaded from server'); - } catch (error) { - if (controller.signal.aborted) { - console.log('Load operation cancelled'); - setStatusMessage('Operation cancelled'); - } else { - console.error('Load failed:', error); - setStatusMessage('Load failed. Please try again.'); - } - } finally { - setIsLoading(false); - setShowProgress(false); - setProgress(0); - setStatusMessage(''); - setAbortController(null); - } + setSelectionModalProps({ + title: 'Load from Server', + confirmLabel: 'Load', + mode: 'load', + defaultSelected: true, + fetcher: async () => { + const res = await fetch('/api/documents?list=true'); + if (!res.ok) throw new Error('Failed to list server documents'); + const data = await res.json(); + // Handle case where API might return error object + if (data.error) throw new Error(data.error); + return data.documents || []; + } + }); + setIsSelectionModalOpen(true); }; const handleImportLibrary = async () => { + setSelectionModalProps({ + title: 'Import from Library', + confirmLabel: 'Import', + mode: 'library', + defaultSelected: false, + fetcher: async () => { + const res = await fetch('/api/documents/library?limit=10000'); + if (!res.ok) throw new Error('Failed to list library documents'); + const data = await res.json(); + return data.documents || []; + } + }); + setIsSelectionModalOpen(true); + }; + + const handleModalConfirm = async (selectedFiles: BaseDocument[]) => { const controller = new AbortController(); setAbortController(controller); + + const mode = selectionModalProps.mode; + + // Close modal? Maybe keep open until started? + // Let's close it here, process starts. + // Actually we keep it open if we want to show loading state INSIDE modal? + // But existing UI uses a separate ProgressPopup. + // So close modal, show popup. + setIsSelectionModalOpen(false); try { - setIsImportingLibrary(true); - setShowProgress(true); - setProgress(0); - setOperationType('library'); - setStatusMessage('Scanning server library...'); - await importDocumentsFromLibrary((progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); + setShowProgress(true); + setProgress(0); + + if (mode === 'save') { + setIsSyncing(true); + setOperationType('sync'); + setStatusMessage('Preparing documents...'); + await syncSelectedDocumentsToServer(selectedFiles, (progress, status) => { + if (controller.signal.aborted) return; + setProgress(progress); + if (status) setStatusMessage(status); + }, controller.signal); + } else if (mode === 'load') { + setIsLoading(true); + setOperationType('load'); + setStatusMessage('Downloading documents...'); + // Need ids + const ids = selectedFiles.map(f => f.id); + await loadSelectedDocumentsFromServer(ids, (progress, status) => { + if (controller.signal.aborted) return; + setProgress(progress); + if (status) setStatusMessage(status); + }, controller.signal); + if (!controller.signal.aborted) setStatusMessage('Documents loaded'); + } else if (mode === 'library') { + setIsImportingLibrary(true); + setOperationType('library'); + setStatusMessage('Importing selected documents...'); + await importSelectedDocuments(selectedFiles, (progress, status) => { + if (controller.signal.aborted) return; + setProgress(progress); + if (status) setStatusMessage(status); + }, controller.signal); + } + } catch (error) { - if (controller.signal.aborted) { - console.log('Library import cancelled'); - setStatusMessage('Operation cancelled'); - } else { - console.error('Library import failed:', error); - setStatusMessage('Library import failed. Please try again.'); - } + if (controller.signal.aborted) { + console.log(`${mode} operation cancelled`); + setStatusMessage('Operation cancelled'); + } else { + console.error(`${mode} failed:`, error); + setStatusMessage(`${mode} failed. Please try again.`); + } } finally { - setIsImportingLibrary(false); - setShowProgress(false); - setProgress(0); - setStatusMessage(''); - setAbortController(null); + setIsSyncing(false); + setIsLoading(false); + setIsImportingLibrary(false); + setShowProgress(false); + setProgress(0); + setStatusMessage(''); + setAbortController(null); } }; @@ -752,6 +795,17 @@ export function SettingsModal() { operationType={operationType} cancelText="Cancel" /> + <DocumentSelectionModal + isOpen={isSelectionModalOpen} + onClose={() => !isImportingLibrary && !isSyncing && !isLoading && setIsSelectionModalOpen(false)} + onConfirm={handleModalConfirm} + title={selectionModalProps.title} + confirmLabel={selectionModalProps.confirmLabel} + isProcessing={false} // Processing happens in ProgressPopup after closing + defaultSelected={selectionModalProps.defaultSelected} + initialFiles={selectionModalProps.initialFiles} + fetcher={selectionModalProps.fetcher} + /> </> ); } diff --git a/src/lib/dexie.ts b/src/lib/dexie.ts index 9b671e6..e43d39e 100644 --- a/src/lib/dexie.ts +++ b/src/lib/dexie.ts @@ -731,6 +731,67 @@ export async function syncDocumentsToServer( return { lastSync: Date.now() }; } +export async function syncSelectedDocumentsToServer( + documents: BaseDocument[], + onProgress?: (progress: number, status?: string) => void, + signal?: AbortSignal, +): Promise<{ lastSync: number }> { + // Re-use logic from syncDocumentsToServer but only for specific documents + // Actually, syncDocumentsToServer fetches all docs from DB. + // We need to fetch the *full content* of the selected docs from DB. + + const fullDocs: SyncedDocument[] = []; + let processed = 0; + + for (const doc of documents) { + if (doc.type === 'pdf') { + const data = await getPdfDocument(doc.id); + if (data) fullDocs.push({ ...data, type: 'pdf', data: Array.from(new Uint8Array(data.data)) }); + } else if (doc.type === 'epub') { + const data = await getEpubDocument(doc.id); + if (data) fullDocs.push({ ...data, type: 'epub', data: Array.from(new Uint8Array(data.data)) }); + } else { + const data = await getHtmlDocument(doc.id); + if (data) { + const encoder = new TextEncoder(); + fullDocs.push({ ...data, type: 'html', data: Array.from(encoder.encode(data.data)) }); + } + } + processed++; + if (onProgress) onProgress((processed / documents.length) * 50, `Preparing ${processed}/${documents.length}...`); + } + + if (onProgress) onProgress(50, 'Uploading to server...'); + + const response = await fetch('/api/documents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ documents: fullDocs }), + signal, + }); + + if (!response.ok) { + throw new Error('Failed to sync documents to server'); + } + + const payload = (await response.json().catch(() => null)) as + | { stored?: Array<{ oldId: string; id: string }> } + | null; + const stored = payload?.stored ?? []; + for (const mapping of stored) { + if (!mapping || typeof mapping.oldId !== 'string' || typeof mapping.id !== 'string') continue; + if (mapping.oldId === mapping.id) continue; + await applyDocumentIdMapping(mapping.oldId, mapping.id); + } + + if (onProgress) { + onProgress(100, 'Upload complete!'); + } + + return { lastSync: Date.now() }; +} + + export async function loadDocumentsFromServer( onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal, @@ -754,6 +815,52 @@ export async function loadDocumentsFromServer( onProgress(40, 'Parsing documents...'); } + await saveSyncedDocumentsLocally(documents, onProgress); + + if (onProgress) { + onProgress(100, 'Load complete!'); + } + + return { lastSync: Date.now() }; +} + +export async function loadSelectedDocumentsFromServer( + selectedIds: string[], + onProgress?: (progress: number, status?: string) => void, + signal?: AbortSignal, +): Promise<{ lastSync: number }> { + if (onProgress) { + onProgress(10, 'Starting download...'); + } + + // Use new filtered API + const idsParam = selectedIds.join(','); + const response = await fetch(`/api/documents?ids=${encodeURIComponent(idsParam)}`, { 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...'); + } + + await saveSyncedDocumentsLocally(documents, onProgress); + + if (onProgress) { + onProgress(100, 'Load complete!'); + } + + return { lastSync: Date.now() }; +} + +async function saveSyncedDocumentsLocally(documents: SyncedDocument[], onProgress?: (progress: number, status?: string) => void) { const textDecoder = new TextDecoder(); for (let i = 0; i < documents.length; i++) { @@ -801,39 +908,15 @@ export async function loadDocumentsFromServer( onProgress(40 + ((i + 1) / documents.length) * 50, `Processing document ${i + 1}/${documents.length}...`); } } - - if (onProgress) { - onProgress(100, 'Load complete!'); - } - - return { lastSync: Date.now() }; } -export async function importDocumentsFromLibrary( + +export async function importSelectedDocuments( + documents: BaseDocument[], onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal, ): Promise<void> { - if (onProgress) { - onProgress(5, 'Scanning server library...'); - } - - const listResponse = await fetch('/api/documents/library', { signal }); - if (!listResponse.ok) { - throw new Error('Failed to list library documents'); - } - - const { documents } = (await listResponse.json()) as { documents: BaseDocument[] }; - - if (documents.length === 0) { - if (onProgress) { - onProgress(100, 'No documents found in server library'); - } - return; - } - - if (onProgress) { - onProgress(10, `Found ${documents.length} documents. Importing...`); - } + if (documents.length === 0) return; const textDecoder = new TextDecoder(); @@ -890,8 +973,39 @@ export async function importDocumentsFromLibrary( onProgress(10 + ((i + 1) / documents.length) * 85, `Imported ${i + 1}/${documents.length}`); } } +} + +export async function importDocumentsFromLibrary( + onProgress?: (progress: number, status?: string) => void, + signal?: AbortSignal, +): Promise<void> { + if (onProgress) { + onProgress(5, 'Scanning server library...'); + } + + const listResponse = await fetch('/api/documents/library', { signal }); + if (!listResponse.ok) { + throw new Error('Failed to list library documents'); + } + + const { documents } = (await listResponse.json()) as { documents: BaseDocument[] }; + + if (documents.length === 0) { + if (onProgress) { + onProgress(100, 'No documents found in server library'); + } + return; + } + + if (onProgress) { + onProgress(10, `Found ${documents.length} documents. Importing...`); + } + + await importSelectedDocuments(documents, onProgress, signal); if (onProgress) { onProgress(100, 'Library import complete!'); } } + + From 33d49d696602611f44474f292ffb7b1a775e6eac Mon Sep 17 00:00:00 2001 From: Richard R <me@richardr.dev> Date: Mon, 19 Jan 2026 16:40:04 -0700 Subject: [PATCH 08/12] fix: Enhance audiobook API validation and prevent critical meta file deletion --- .github/workflows/playwright.yml | 2 +- README.md | 2 +- package.json | 10 +- pnpm-lock.yaml | 518 +++++++++++++------------- src/app/api/audiobook/route.ts | 3 +- src/app/api/audiobook/status/route.ts | 8 +- src/lib/server/docstore.ts | 2 +- tests/export.spec.ts | 2 +- 8 files changed, 277 insertions(+), 270 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 94d2033..bd4873b 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,7 +1,7 @@ name: Playwright Tests on: push: - branches: [ main, master, 'v*.*.*' ] + branches: [ main, master ] pull_request: branches: [ main, master ] jobs: diff --git a/README.md b/README.md index 748cd71..44354eb 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ docker run --name openreader-webui \ -v /path/to/your/library:/app/docstore/library:ro \ ghcr.io/richardr1126/openreader-webui:latest ``` -Seperate from the docstore volume, this will mount an external folder to `/app/docstore/library` (read-only recommended). This allows you to connect OpenReader to an existing library of documents. +Separate from the main docstore volume, this mounts an external folder into the container at `/app/docstore/library` (read-only recommended) so OpenReader can use an existing library of documents. To import from the mounted library: **Settings → Documents → Server Library Import** diff --git a/package.json b/package.json index 1053997..25d17db 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "@types/howler": "^2.2.12", "@types/uuid": "^10.0.0", "@vercel/analytics": "^1.6.1", - "cmpstr": "^3.0.4", - "compromise": "^14.14.4", + "cmpstr": "^3.1.1", + "compromise": "^14.14.5", "core-js": "^3.47.0", "dexie": "^4.2.1", "dexie-react-hooks": "^4.2.0", @@ -41,10 +41,10 @@ "@eslint/eslintrc": "^3.3.3", "@playwright/test": "^1.57.0", "@tailwindcss/typography": "^0.5.19", - "@types/node": "^20.19.26", - "@types/react": "^19.2.7", + "@types/node": "^20.19.30", + "@types/react": "^19.2.8", "@types/react-dom": "^19.2.3", - "eslint": "^9.39.1", + "eslint": "^9.39.2", "eslint-config-next": "^15.5.9", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccd5a8a..a6cac9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,11 +21,11 @@ importers: specifier: ^1.6.1 version: 1.6.1(next@15.5.9(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) cmpstr: - specifier: ^3.0.4 - version: 3.0.4 + specifier: ^3.1.1 + version: 3.1.1 compromise: - specifier: ^14.14.4 - version: 14.14.4 + specifier: ^14.14.5 + version: 14.14.5 core-js: specifier: ^3.47.0 version: 3.47.0 @@ -34,7 +34,7 @@ importers: version: 4.2.1 dexie-react-hooks: specifier: ^4.2.0 - version: 4.2.0(@types/react@19.2.7)(dexie@4.2.1)(react@19.2.3) + version: 4.2.0(@types/react@19.2.8)(dexie@4.2.1)(react@19.2.3) epubjs: specifier: ^0.3.93 version: 0.3.93 @@ -58,7 +58,7 @@ importers: version: 19.2.3 react-dnd: specifier: ^16.0.1 - version: 16.0.1(@types/node@20.19.26)(@types/react@19.2.7)(react@19.2.3) + version: 16.0.1(@types/node@20.19.30)(@types/react@19.2.8)(react@19.2.3) react-dnd-html5-backend: specifier: ^16.0.1 version: 16.0.1 @@ -73,10 +73,10 @@ importers: version: 2.6.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.7)(react@19.2.3) + version: 10.1.0(@types/react@19.2.8)(react@19.2.3) react-pdf: specifier: ^9.2.1 - version: 9.2.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 9.2.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react-reader: specifier: ^2.0.15 version: 2.0.15(react@19.2.3) @@ -97,20 +97,20 @@ importers: specifier: ^0.5.19 version: 0.5.19(tailwindcss@3.4.19) '@types/node': - specifier: ^20.19.26 - version: 20.19.26 + specifier: ^20.19.30 + version: 20.19.30 '@types/react': - specifier: ^19.2.7 - version: 19.2.7 + specifier: ^19.2.8 + version: 19.2.8 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.7) + version: 19.2.3(@types/react@19.2.8) eslint: - specifier: ^9.39.1 - version: 9.39.1(jiti@1.21.7) + specifier: ^9.39.2 + version: 9.39.2(jiti@1.21.7) eslint-config-next: specifier: ^15.5.9 - version: 15.5.9(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + version: 15.5.9(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -127,21 +127,21 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} - '@emnapi/core@1.7.1': - resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.7.1': - resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -166,8 +166,8 @@ packages: resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.1': - resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -450,14 +450,14 @@ packages: engines: {node: '>=18'} hasBin: true - '@react-aria/focus@3.21.2': - resolution: {integrity: sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==} + '@react-aria/focus@3.21.3': + resolution: {integrity: sha512-FsquWvjSCwC2/sBk4b+OqJyONETUIXQ2vM0YdPAuC+QFQh2DT6TIBo6dOZVSezlhudDla69xFBd6JvCFq1AbUw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/interactions@3.25.6': - resolution: {integrity: sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==} + '@react-aria/interactions@3.26.0': + resolution: {integrity: sha512-AAEcHiltjfbmP1i9iaVw34Mb7kbkiHpYdqieWufldh4aplWgsF11YQZOfaCJW4QoR2ML4Zzoa9nfFwLXA52R7Q==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -468,8 +468,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/utils@3.31.0': - resolution: {integrity: sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==} + '@react-aria/utils@3.32.0': + resolution: {integrity: sha512-/7Rud06+HVBIlTwmwmJa2W8xVtgxgzm0+kLbuFooZRzKDON6hhozS1dOMR/YLMxyJOaYOTpImcP4vRR9gL1hEg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -486,8 +486,8 @@ packages: '@react-stately/flags@3.1.2': resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==} - '@react-stately/utils@3.10.8': - resolution: {integrity: sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==} + '@react-stately/utils@3.11.0': + resolution: {integrity: sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -505,22 +505,22 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@swc/helpers@0.5.17': - resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@swc/helpers@0.5.18': + resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} '@tailwindcss/typography@0.5.19': resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tanstack/react-virtual@3.13.13': - resolution: {integrity: sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==} + '@tanstack/react-virtual@3.13.18': + resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/virtual-core@3.13.13': - resolution: {integrity: sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==} + '@tanstack/virtual-core@3.13.18': + resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -562,16 +562,16 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@20.19.26': - resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==} + '@types/node@20.19.30': + resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.7': - resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/react@19.2.8': + resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==} '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -582,63 +582,63 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - '@typescript-eslint/eslint-plugin@8.49.0': - resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==} + '@typescript-eslint/eslint-plugin@8.53.1': + resolution: {integrity: sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.49.0 + '@typescript-eslint/parser': ^8.53.1 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.49.0': - resolution: {integrity: sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==} + '@typescript-eslint/parser@8.53.1': + resolution: {integrity: sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.49.0': - resolution: {integrity: sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==} + '@typescript-eslint/project-service@8.53.1': + resolution: {integrity: sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.49.0': - resolution: {integrity: sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==} + '@typescript-eslint/scope-manager@8.53.1': + resolution: {integrity: sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.49.0': - resolution: {integrity: sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==} + '@typescript-eslint/tsconfig-utils@8.53.1': + resolution: {integrity: sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.49.0': - resolution: {integrity: sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==} + '@typescript-eslint/type-utils@8.53.1': + resolution: {integrity: sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.49.0': - resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==} + '@typescript-eslint/types@8.53.1': + resolution: {integrity: sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.49.0': - resolution: {integrity: sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==} + '@typescript-eslint/typescript-estree@8.53.1': + resolution: {integrity: sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.49.0': - resolution: {integrity: sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==} + '@typescript-eslint/utils@8.53.1': + resolution: {integrity: sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.49.0': - resolution: {integrity: sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==} + '@typescript-eslint/visitor-keys@8.53.1': + resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -862,8 +862,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.11.0: - resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} axobject-query@4.1.0: @@ -919,11 +919,11 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001760: - resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + caniuse-lite@1.0.30001765: + resolution: {integrity: sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==} - canvas@3.2.0: - resolution: {integrity: sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==} + canvas@3.2.1: + resolution: {integrity: sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==} engines: {node: ^18.12.0 || >= 20.9.0} ccount@2.0.1: @@ -959,8 +959,8 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - cmpstr@3.0.4: - resolution: {integrity: sha512-GPkstf+gjng88j2UNUMGipRLr+HpnZq8Qct6vlAeRdFYKiFJYa2rFX1y4ikxwKPsttIBOZQ2lhpMBVZWRiSuVw==} + cmpstr@3.1.1: + resolution: {integrity: sha512-gY221H032+sqFBlCHTBzlOyLtTEiqvLi3G4C7O8Kwro/8RmmQP12M3w2TtAD6tH818iKpvdy9ytybotHql/tDA==} engines: {node: '>=18.0.0'} color-convert@2.0.1: @@ -981,8 +981,8 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} - compromise@14.14.4: - resolution: {integrity: sha512-QdbJwronwxeqb7a5KFK/+Y5YieZ4PE1f7ai0vU58Pp4jih+soDCBMuKVbhDEPQ+6+vI3vSiG4UAAjTAXLJw1Qw==} + compromise@14.14.5: + resolution: {integrity: sha512-9qWxpOWo4crzvbdxAYDTwO6z0WljXwi6mL7CqCjAXKn7QtFijmSj7fCyAqGWldCVT2zNboMvg4kNL06drMg2Vw==} engines: {node: '>=12.0.0'} concat-map@0.0.1: @@ -1042,8 +1042,8 @@ packages: supports-color: optional: true - decode-named-character-reference@1.2.0: - resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} @@ -1119,8 +1119,8 @@ packages: epubjs@0.3.93: resolution: {integrity: sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==} - es-abstract@1.24.0: - resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} es-define-property@1.0.1: @@ -1131,8 +1131,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.2.1: - resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} + es-iterator-helpers@1.2.2: + resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} es-object-atoms@1.1.1: @@ -1256,8 +1256,8 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.39.1: - resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -1274,8 +1274,8 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -1327,8 +1327,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1981,8 +1981,8 @@ packages: sass: optional: true - node-abi@3.85.0: - resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} + node-abi@3.86.0: + resolution: {integrity: sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==} engines: {node: '>=10'} node-addon-api@7.1.1: @@ -2524,8 +2524,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tabbable@6.3.0: - resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} @@ -2566,8 +2566,8 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -2689,8 +2689,8 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} which@2.0.2: @@ -2716,15 +2716,15 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@babel/runtime@7.28.4': {} + '@babel/runtime@7.28.6': {} - '@emnapi/core@1.7.1': + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.7.1': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true @@ -2734,9 +2734,9 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': dependencies: - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -2771,7 +2771,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.39.1': {} + '@eslint/js@9.39.2': {} '@eslint/object-schema@2.1.7': {} @@ -2801,16 +2801,16 @@ snapshots: '@floating-ui/utils': 0.2.10 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - tabbable: 6.3.0 + tabbable: 6.4.0 '@floating-ui/utils@0.2.10': {} '@headlessui/react@2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@floating-ui/react': 0.26.28(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-aria/focus': 3.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-aria/interactions': 3.25.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-virtual': 3.13.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-aria/focus': 3.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-aria/interactions': 3.26.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-virtual': 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) @@ -2911,7 +2911,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.8.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -2939,8 +2939,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.7.1 - '@emnapi/runtime': 1.7.1 + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 '@tybys/wasm-util': 0.10.1 optional: true @@ -2984,7 +2984,7 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 + fastq: 1.20.1 '@nolyfill/is-core-module@1.0.39': {} @@ -2992,38 +2992,38 @@ snapshots: dependencies: playwright: 1.57.0 - '@react-aria/focus@3.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@react-aria/focus@3.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@react-aria/interactions': 3.25.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-aria/utils': 3.31.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-aria/interactions': 3.26.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-aria/utils': 3.32.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-types/shared': 3.32.1(react@19.2.3) - '@swc/helpers': 0.5.17 + '@swc/helpers': 0.5.18 clsx: 2.1.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@react-aria/interactions@3.25.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@react-aria/interactions@3.26.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@react-aria/ssr': 3.9.10(react@19.2.3) - '@react-aria/utils': 3.31.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-aria/utils': 3.32.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-stately/flags': 3.1.2 '@react-types/shared': 3.32.1(react@19.2.3) - '@swc/helpers': 0.5.17 + '@swc/helpers': 0.5.18 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) '@react-aria/ssr@3.9.10(react@19.2.3)': dependencies: - '@swc/helpers': 0.5.17 + '@swc/helpers': 0.5.18 react: 19.2.3 - '@react-aria/utils@3.31.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@react-aria/utils@3.32.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@react-aria/ssr': 3.9.10(react@19.2.3) '@react-stately/flags': 3.1.2 - '@react-stately/utils': 3.10.8(react@19.2.3) + '@react-stately/utils': 3.11.0(react@19.2.3) '@react-types/shared': 3.32.1(react@19.2.3) - '@swc/helpers': 0.5.17 + '@swc/helpers': 0.5.18 clsx: 2.1.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -3036,11 +3036,11 @@ snapshots: '@react-stately/flags@3.1.2': dependencies: - '@swc/helpers': 0.5.17 + '@swc/helpers': 0.5.18 - '@react-stately/utils@3.10.8(react@19.2.3)': + '@react-stately/utils@3.11.0(react@19.2.3)': dependencies: - '@swc/helpers': 0.5.17 + '@swc/helpers': 0.5.18 react: 19.2.3 '@react-types/shared@3.32.1(react@19.2.3)': @@ -3055,7 +3055,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@swc/helpers@0.5.17': + '@swc/helpers@0.5.18': dependencies: tslib: 2.8.1 @@ -3064,13 +3064,13 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.19 - '@tanstack/react-virtual@3.13.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-virtual@3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/virtual-core': 3.13.13 + '@tanstack/virtual-core': 3.13.18 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@tanstack/virtual-core@3.13.13': {} + '@tanstack/virtual-core@3.13.18': {} '@tybys/wasm-util@0.10.1': dependencies: @@ -3109,22 +3109,22 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 20.19.26 + '@types/node': 20.19.30 form-data: 4.0.5 '@types/node@18.19.130': dependencies: undici-types: 5.26.5 - '@types/node@20.19.26': + '@types/node@20.19.30': dependencies: undici-types: 6.21.0 - '@types/react-dom@19.2.3(@types/react@19.2.7)': + '@types/react-dom@19.2.3(@types/react@19.2.8)': dependencies: - '@types/react': 19.2.7 + '@types/react': 19.2.8 - '@types/react@19.2.7': + '@types/react@19.2.8': dependencies: csstype: 3.2.3 @@ -3134,95 +3134,95 @@ snapshots: '@types/uuid@10.0.0': {} - '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.49.0 - eslint: 9.39.1(jiti@1.21.7) + '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.53.1 + '@typescript-eslint/type-utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.53.1 + eslint: 9.39.2(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.49.0 + '@typescript-eslint/scope-manager': 8.53.1 + '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.53.1 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.49.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.53.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) - '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3) + '@typescript-eslint/types': 8.53.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.49.0': + '@typescript-eslint/scope-manager@8.53.1': dependencies: - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/visitor-keys': 8.49.0 + '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/visitor-keys': 8.53.1 - '@typescript-eslint/tsconfig-utils@8.49.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.53.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) - ts-api-utils: 2.1.0(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.49.0': {} + '@typescript-eslint/types@8.53.1': {} - '@typescript-eslint/typescript-estree@8.49.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.53.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.49.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/visitor-keys': 8.49.0 + '@typescript-eslint/project-service': 8.53.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3) + '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/visitor-keys': 8.53.1 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - eslint: 9.39.1(jiti@1.21.7) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.53.1 + '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.49.0': + '@typescript-eslint/visitor-keys@8.53.1': dependencies: - '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/types': 8.53.1 eslint-visitor-keys: 4.2.1 '@ungap/structured-clone@1.3.0': {} @@ -3341,7 +3341,7 @@ snapshots: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 is-string: 1.1.1 @@ -3351,7 +3351,7 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-object-atoms: 1.1.1 es-shim-unscopables: 1.1.0 @@ -3361,7 +3361,7 @@ snapshots: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-object-atoms: 1.1.1 es-shim-unscopables: 1.1.0 @@ -3370,21 +3370,21 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-shim-unscopables: 1.1.0 array.prototype.flatmap@1.3.3: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-shim-unscopables: 1.1.0 array.prototype.tosorted@1.1.4: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-shim-unscopables: 1.1.0 @@ -3393,7 +3393,7 @@ snapshots: array-buffer-byte-length: 1.0.2 call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 @@ -3410,7 +3410,7 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.11.0: {} + axe-core@4.11.1: {} axobject-query@4.1.0: {} @@ -3470,9 +3470,9 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001760: {} + caniuse-lite@1.0.30001765: {} - canvas@3.2.0: + canvas@3.2.1: dependencies: node-addon-api: 7.1.1 prebuild-install: 7.1.3 @@ -3512,7 +3512,7 @@ snapshots: clsx@2.1.1: {} - cmpstr@3.0.4: {} + cmpstr@3.1.1: {} color-convert@2.0.1: dependencies: @@ -3528,7 +3528,7 @@ snapshots: commander@4.1.1: {} - compromise@14.14.4: + compromise@14.14.5: dependencies: efrt: 2.7.0 grad-school: 0.0.5 @@ -3583,7 +3583,7 @@ snapshots: dependencies: ms: 2.1.3 - decode-named-character-reference@1.2.0: + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -3620,9 +3620,9 @@ snapshots: dependencies: dequal: 2.0.3 - dexie-react-hooks@4.2.0(@types/react@19.2.7)(dexie@4.2.1)(react@19.2.3): + dexie-react-hooks@4.2.0(@types/react@19.2.8)(dexie@4.2.1)(react@19.2.3): dependencies: - '@types/react': 19.2.7 + '@types/react': 19.2.8 dexie: 4.2.1 react: 19.2.3 @@ -3669,7 +3669,7 @@ snapshots: marks-pane: 1.0.9 path-webpack: 0.0.3 - es-abstract@1.24.0: + es-abstract@1.24.1: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 @@ -3724,18 +3724,18 @@ snapshots: typed-array-byte-offset: 1.0.4 typed-array-length: 1.0.7 unbox-primitive: 1.1.0 - which-typed-array: 1.1.19 + which-typed-array: 1.1.20 es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-iterator-helpers@1.2.1: + es-iterator-helpers@1.2.2: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-set-tostringtag: 2.1.0 function-bind: 1.1.2 @@ -3792,19 +3792,19 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@15.5.9(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3): + eslint-config-next@15.5.9(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 15.5.9 '@rushstack/eslint-patch': 1.15.0 - '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.1(jiti@1.21.7) + '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@1.21.7)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@1.21.7)) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -3820,33 +3820,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) get-tsconfig: 4.13.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.1(jiti@1.21.7) + '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -3855,9 +3855,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -3869,23 +3869,23 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@1.21.7)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.11.0 + axe-core: 4.11.1 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -3894,19 +3894,19 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@5.2.0(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-react-hooks@5.2.0(eslint@9.39.2(jiti@1.21.7)): dependencies: - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.2(jiti@1.21.7) - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@1.21.7)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.2.1 - eslint: 9.39.1(jiti@1.21.7) + es-iterator-helpers: 1.2.2 + eslint: 9.39.2(jiti@1.21.7) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -3929,15 +3929,15 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.1(jiti@1.21.7): + eslint@9.39.2(jiti@1.21.7): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.1 + '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 @@ -3951,7 +3951,7 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.6.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -3983,7 +3983,7 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -4035,7 +4035,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fastq@1.19.1: + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4367,7 +4367,7 @@ snapshots: is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.19 + which-typed-array: 1.1.20 is-weakmap@2.0.2: {} @@ -4495,7 +4495,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 - decode-named-character-reference: 1.2.0 + decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 micromark: 4.0.2 @@ -4637,15 +4637,15 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - merge-refs@1.3.0(@types/react@19.2.7): + merge-refs@1.3.0(@types/react@19.2.8): optionalDependencies: - '@types/react': 19.2.7 + '@types/react': 19.2.8 merge2@1.4.1: {} micromark-core-commonmark@2.0.3: dependencies: - decode-named-character-reference: 1.2.0 + decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-factory-destination: 2.0.1 micromark-factory-label: 2.0.1 @@ -4778,7 +4778,7 @@ snapshots: micromark-util-decode-string@2.0.1: dependencies: - decode-named-character-reference: 1.2.0 + decode-named-character-reference: 1.3.0 micromark-util-character: 2.1.1 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-symbol: 2.0.1 @@ -4816,7 +4816,7 @@ snapshots: dependencies: '@types/debug': 4.1.12 debug: 4.4.3 - decode-named-character-reference: 1.2.0 + decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 micromark-factory-space: 2.0.1 @@ -4884,7 +4884,7 @@ snapshots: dependencies: '@next/env': 15.5.9 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001760 + caniuse-lite: 1.0.30001765 postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -4904,7 +4904,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - node-abi@3.85.0: + node-abi@3.86.0: dependencies: semver: 7.7.3 optional: true @@ -4948,14 +4948,14 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-object-atoms: 1.1.1 object.groupby@1.0.3: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 object.values@1.2.1: dependencies: @@ -5015,7 +5015,7 @@ snapshots: '@types/unist': 2.0.11 character-entities-legacy: 3.0.0 character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.2.0 + decode-named-character-reference: 1.3.0 is-alphanumerical: 2.0.1 is-decimal: 2.0.1 is-hexadecimal: 2.0.1 @@ -5033,7 +5033,7 @@ snapshots: pdfjs-dist@4.8.69: optionalDependencies: - canvas: 3.2.0 + canvas: 3.2.1 path2d: 0.2.2 picocolors@1.1.1: {} @@ -5112,7 +5112,7 @@ snapshots: minimist: 1.2.8 mkdirp-classic: 0.5.3 napi-build-utils: 2.0.0 - node-abi: 3.85.0 + node-abi: 3.86.0 pump: 3.0.3 rc: 1.2.8 simple-get: 4.0.1 @@ -5154,7 +5154,7 @@ snapshots: dependencies: dnd-core: 16.0.1 - react-dnd@16.0.1(@types/node@20.19.26)(@types/react@19.2.7)(react@19.2.3): + react-dnd@16.0.1(@types/node@20.19.30)(@types/react@19.2.8)(react@19.2.3): dependencies: '@react-dnd/invariant': 4.0.2 '@react-dnd/shallowequal': 4.0.2 @@ -5163,8 +5163,8 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 19.2.3 optionalDependencies: - '@types/node': 20.19.26 - '@types/react': 19.2.7 + '@types/node': 20.19.30 + '@types/react': 19.2.8 react-dom@19.2.3(react@19.2.3): dependencies: @@ -5187,11 +5187,11 @@ snapshots: react-is@16.13.1: {} - react-markdown@10.1.0(@types/react@19.2.7)(react@19.2.3): + react-markdown@10.1.0(@types/react@19.2.8)(react@19.2.3): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.2.7 + '@types/react': 19.2.8 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 @@ -5205,20 +5205,20 @@ snapshots: transitivePeerDependencies: - supports-color - react-pdf@9.2.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + react-pdf@9.2.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: clsx: 2.1.1 dequal: 2.0.3 make-cancellable-promise: 1.3.2 make-event-props: 1.6.2 - merge-refs: 1.3.0(@types/react@19.2.7) + merge-refs: 1.3.0(@types/react@19.2.8) pdfjs-dist: 4.8.69 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tiny-invariant: 1.3.3 warning: 4.0.3 optionalDependencies: - '@types/react': 19.2.7 + '@types/react': 19.2.8 react-reader@2.0.15(react@19.2.3): dependencies: @@ -5260,13 +5260,13 @@ snapshots: redux@4.2.1: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 @@ -5483,14 +5483,14 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 @@ -5504,7 +5504,7 @@ snapshots: string.prototype.repeat@1.0.0: dependencies: define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 string.prototype.trim@1.2.10: dependencies: @@ -5512,7 +5512,7 @@ snapshots: call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-object-atoms: 1.1.1 has-property-descriptors: 1.0.2 @@ -5581,7 +5581,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tabbable@6.3.0: {} + tabbable@6.4.0: {} tailwindcss@3.4.19: dependencies: @@ -5653,7 +5653,7 @@ snapshots: trough@2.2.0: {} - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -5839,7 +5839,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.19 + which-typed-array: 1.1.20 which-collection@1.0.2: dependencies: @@ -5848,7 +5848,7 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-typed-array@1.1.19: + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.8 diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 6042fde..8c64e44 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -203,7 +203,8 @@ export async function POST(request: NextRequest) { existingSettings?.format ?? incomingSettings?.format ?? requestedFormat; - const postSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1; + const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1; + const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1; // Use provided chapter index or find the next available index robustly (handles gaps) let chapterIndex: number; diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index e2adff5..6761546 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -16,10 +16,16 @@ function getAudiobooksRootDir(request: NextRequest): string { return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; } +const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; + +function isSafeId(value: string): boolean { + return SAFE_ID_REGEX.test(value); +} + export async function GET(request: NextRequest) { try { const bookId = request.nextUrl.searchParams.get('bookId'); - if (!bookId) { + if (!bookId || !isSafeId(bookId)) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts index 07ac725..18c3a5f 100644 --- a/src/lib/server/docstore.ts +++ b/src/lib/server/docstore.ts @@ -512,7 +512,7 @@ async function normalizeAudiobookDirectoryChapterLayout(intermediateDir: string) if (/^\d+-input\.mp3$/i.test(file)) { await unlink(path.join(intermediateDir, file)).catch(() => {}); } - if (file.endsWith('.meta.json')) { + if (file.endsWith('.meta.json') && file !== 'audiobook.meta.json') { await unlink(path.join(intermediateDir, file)).catch(() => {}); } } diff --git a/tests/export.spec.ts b/tests/export.spec.ts index 83fc6df..2d3d727 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -75,7 +75,7 @@ async function expectChaptersBackendState(page: Page, bookId: string) { async function resetAudiobookById(page: Page, bookId: string) { const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`); - expect(res.ok()).toBeTruthy(); + expect(res.ok() || res.status() === 404).toBeTruthy(); } async function resetAudiobookIfPresent(page: Page) { From 47d838039af04f17e942f7f81a95af29d7a7d59e Mon Sep 17 00:00:00 2001 From: Richard R <me@richardr.dev> Date: Mon, 19 Jan 2026 17:04:02 -0700 Subject: [PATCH 09/12] feat: Add `bookId` validation to API routes and implement robust, length-limited filename generation with unit tests. --- src/app/api/audiobook/route.ts | 17 +++++++++++ src/lib/server/docstore.ts | 17 ++++++++++- tests/docstore_unit.spec.ts | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/docstore_unit.spec.ts diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 8c64e44..d2e639d 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -140,6 +140,12 @@ function buildAtempoFilter(speed: number): string { return `atempo=2.0,atempo=${second.toFixed(3)}`; } +const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; + +function isSafeId(value: string): boolean { + return SAFE_ID_REGEX.test(value); +} + export async function POST(request: NextRequest) { try { // Parse the request body @@ -155,6 +161,11 @@ export async function POST(request: NextRequest) { // Generate or use existing book ID const bookId = data.bookId || randomUUID(); + + if (!isSafeId(bookId)) { + return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); // Create intermediate directory @@ -324,6 +335,9 @@ export async function GET(request: NextRequest) { if (!bookId) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } + if (!isSafeId(bookId)) { + return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); + } if (!(await isAudiobooksV1Ready())) { return NextResponse.json( @@ -514,6 +528,9 @@ export async function DELETE(request: NextRequest) { if (!bookId) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } + if (!isSafeId(bookId)) { + return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); + } if (!(await isAudiobooksV1Ready())) { return NextResponse.json( diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts index 18c3a5f..54910e7 100644 --- a/src/lib/server/docstore.ts +++ b/src/lib/server/docstore.ts @@ -101,6 +101,20 @@ function safeDocumentName(rawName: string, fallback: string): string { return baseName.replaceAll('\u0000', '').slice(0, 240) || fallback; } +export function getMigratedDocumentFileName(id: string, name: string): string { + const prefix = `${id}__`; + const encodedName = encodeURIComponent(name); + let targetFileName = `${prefix}${encodedName}`; + + // Ensure total filename length is within safe limits (e.g. 240 chars). + // If too long, use a deterministic hash of the name instead of the full encoded name. + if (targetFileName.length > 240) { + const nameHash = createHash('sha256').update(name).digest('hex').slice(0, 32); + targetFileName = `${prefix}truncated-${nameHash}`; + } + return targetFileName; +} + export async function ensureDocumentsV1Ready(): Promise<boolean> { await mkdir(DOCSTORE_DIR, { recursive: true }); await mkdir(DOCUMENTS_V1_DIR, { recursive: true }); @@ -149,7 +163,8 @@ export async function ensureDocumentsV1Ready(): Promise<boolean> { const id = createHash('sha256').update(content).digest('hex'); const fallbackName = `${id}.${metadata.type}`; const name = safeDocumentName(metadata.name, fallbackName); - const targetFileName = `${id}__${encodeURIComponent(name)}`; + + const targetFileName = getMigratedDocumentFileName(id, name); const targetPath = path.join(DOCUMENTS_V1_DIR, targetFileName); if (!existsSync(targetPath)) { diff --git a/tests/docstore_unit.spec.ts b/tests/docstore_unit.spec.ts new file mode 100644 index 0000000..fda3d92 --- /dev/null +++ b/tests/docstore_unit.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; +import { getMigratedDocumentFileName } from '@/lib/server/docstore'; + +test.describe('Docstore Filename Safety', () => { + const id = 'a'.repeat(64); // Simulate sha256 hex ID + + test('should generate standard filename for short names', () => { + const name = 'test-file.pdf'; + const result = getMigratedDocumentFileName(id, name); + expect(result).toBe(`${id}__test-file.pdf`); + expect(result.length).toBeLessThanOrEqual(240); + }); + + test('should truncate very long names', () => { + const longName = 'a'.repeat(300); + const result = getMigratedDocumentFileName(id, longName); + + expect(result.length).toBeLessThanOrEqual(240); + expect(result).toContain('truncated-'); + expect(result.startsWith(`${id}__`)).toBeTruthy(); + }); + + test('should truncate names that become too long after encoding', () => { + // Chinese characters take 9 chars each when encoded (%XX%XX%XX) + const specialName = '特殊字符'.repeat(30); + const result = getMigratedDocumentFileName(id, specialName); + + expect(result.length).toBeLessThanOrEqual(240); + if (result.includes('truncated-')) { + expect(result).toContain('truncated-'); + } else { + // If it fits (depends on length), just ensure it is valid + // 30 * 4 = 120 chars raw, but encoded? + // Let's ensure logic works. + } + }); + + test('should handle edge case length exactly', () => { + // Create a name that would result in exactly 241 chars to trigger truncation + // Prefix is 64 + 2 = 66 chars. + // Max allowed = 240. + // Available for name = 240 - 66 = 174. + // If we give 175 chars, it should truncate. + const name = 'a'.repeat(175); + const result = getMigratedDocumentFileName(id, name); + expect(result).toContain('truncated-'); + }); + + test('should not truncate if exactly at limit', () => { + const name = 'a'.repeat(174); + const result = getMigratedDocumentFileName(id, name); + expect(result.length).toBe(240); + expect(result).not.toContain('truncated-'); + }); +}); From 9c941b57edcbd5272fad047768f890c98f20555c Mon Sep 17 00:00:00 2001 From: Richard R <me@richardr.dev> Date: Mon, 19 Jan 2026 17:17:51 -0700 Subject: [PATCH 10/12] fix: Enhance path sanitization in `getAudiobooksDir` to prevent directory traversal and update `getMigratedDocumentFileName` tests to assert `truncated-` prefix and hash suffix. --- src/app/api/audiobook/route.ts | 11 +++++++++-- tests/docstore_unit.spec.ts | 12 +++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index d2e639d..9d6d0f3 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { spawn } from 'child_process'; import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/promises'; import { existsSync, createReadStream } from 'fs'; -import { basename, join } from 'path'; +import { basename, join, resolve } from 'path'; import { randomUUID } from 'crypto'; import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio } from '@/lib/server/audiobook'; @@ -15,7 +15,14 @@ function getAudiobooksRootDir(request: NextRequest): string { const raw = request.headers.get('x-openreader-test-namespace')?.trim(); if (!raw) return AUDIOBOOKS_V1_DIR; const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); - return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; + if (!safe || safe === '.' || safe === '..' || safe.includes('..')) { + return AUDIOBOOKS_V1_DIR; + } + const resolved = resolve(AUDIOBOOKS_V1_DIR, safe); + if (!resolved.startsWith(resolve(AUDIOBOOKS_V1_DIR) + '/')) { + return AUDIOBOOKS_V1_DIR; + } + return resolved; } interface ConversionRequest { diff --git a/tests/docstore_unit.spec.ts b/tests/docstore_unit.spec.ts index fda3d92..ccd4879 100644 --- a/tests/docstore_unit.spec.ts +++ b/tests/docstore_unit.spec.ts @@ -26,13 +26,11 @@ test.describe('Docstore Filename Safety', () => { const result = getMigratedDocumentFileName(id, specialName); expect(result.length).toBeLessThanOrEqual(240); - if (result.includes('truncated-')) { - expect(result).toContain('truncated-'); - } else { - // If it fits (depends on length), just ensure it is valid - // 30 * 4 = 120 chars raw, but encoded? - // Let's ensure logic works. - } + expect(result).toContain('truncated-'); + // The implementation replaces the name with a hash, so it should NOT contain the original special chars + // and might not contain % if the hash is hex. + expect(result).toMatch(/truncated-[a-f0-9]{32}$/); + expect(result.startsWith(`${id}__`)).toBeTruthy(); }); test('should handle edge case length exactly', () => { From 8dbf904a2178238fa65ce941907ad12bf153fc61 Mon Sep 17 00:00:00 2001 From: Richard R <me@richardr.dev> Date: Mon, 19 Jan 2026 17:33:29 -0700 Subject: [PATCH 11/12] feat: Implement FFmpeg metadata escaping and robust chapter index validation for audiobook generation, alongside unit test reorganization. --- src/app/api/audiobook/route.ts | 24 +++++++++----- src/lib/server/audiobook.ts | 9 ++++++ tests/unit/audiobook.spec.ts | 32 +++++++++++++++++++ .../docstore.spec.ts} | 2 +- 4 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 tests/unit/audiobook.spec.ts rename tests/{docstore_unit.spec.ts => unit/docstore.spec.ts} (96%) diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 9d6d0f3..15aa4cb 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -5,7 +5,7 @@ import { existsSync, createReadStream } from 'fs'; import { basename, join, resolve } from 'path'; import { randomUUID } from 'crypto'; import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; -import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio } from '@/lib/server/audiobook'; +import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook'; import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; @@ -178,6 +178,9 @@ export async function POST(request: NextRequest) { // Create intermediate directory await mkdir(intermediateDir, { recursive: true }); + const existingChapters = await listStoredChapters(intermediateDir, request.signal); + const hasChapters = existingChapters.length > 0; + const metaPath = join(intermediateDir, 'audiobook.meta.json'); const incomingSettings = data.settings; let existingSettings: AudiobookGenerationSettings | null = null; @@ -187,7 +190,9 @@ export async function POST(request: NextRequest) { existingSettings = null; } - if (existingSettings) { + // Only enforce mismatch check if we already have generated chapters. + // If no chapters exist, we can overwrite/ignore "existing" settings (which might be stale or partial). + if (existingSettings && hasChapters) { if (incomingSettings) { const mismatch = existingSettings.ttsProvider !== incomingSettings.ttsProvider || @@ -203,11 +208,9 @@ export async function POST(request: NextRequest) { ); } } - } else if (incomingSettings) { - await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2)); } - - const existingChapters = await listStoredChapters(intermediateDir, request.signal); + // Note: We deliberately do NOT write the meta file here yet. + // We wait until a chapter is successfully generated/saved below. const existingFormats = new Set(existingChapters.map((c) => c.format)); if (existingFormats.size > 1) { return NextResponse.json( @@ -224,10 +227,15 @@ export async function POST(request: NextRequest) { const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1; const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1; + // Use provided chapter index or find the next available index robustly (handles gaps) // Use provided chapter index or find the next available index robustly (handles gaps) let chapterIndex: number; if (data.chapterIndex !== undefined) { - chapterIndex = data.chapterIndex; + const normalized = Number(data.chapterIndex); + if (!Number.isInteger(normalized) || normalized < 0) { + return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 }); + } + chapterIndex = normalized; } else { const indices = existingChapters.map((c) => c.index); // Find smallest non-negative integer not present @@ -435,7 +443,7 @@ export async function GET(request: NextRequest) { currentTime += chapter.duration; const endMs = Math.floor(currentTime * 1000); - metadata.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${startMs}`, `END=${endMs}`, `title=${chapter.title}`); + metadata.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${startMs}`, `END=${endMs}`, `title=${escapeFFMetadata(chapter.title)}`); } await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n')); diff --git a/src/lib/server/audiobook.ts b/src/lib/server/audiobook.ts index 2ef5c7a..f32a2a5 100644 --- a/src/lib/server/audiobook.ts +++ b/src/lib/server/audiobook.ts @@ -23,6 +23,15 @@ function sanitizeFileStem(value: string): string { .slice(0, 180); } +export function escapeFFMetadata(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/=/g, '\\=') + .replace(/;/g, '\\;') + .replace(/#/g, '\\#') + .replace(/\r|\n/g, ' '); +} + export function encodeChapterTitleTag(index: number, title: string): string { const safeTitle = sanitizeTagValue(title) || `Chapter ${index + 1}`; const prefix = String(index + 1).padStart(4, '0'); diff --git a/tests/unit/audiobook.spec.ts b/tests/unit/audiobook.spec.ts new file mode 100644 index 0000000..1d990ad --- /dev/null +++ b/tests/unit/audiobook.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; +import { escapeFFMetadata } from '../../src/lib/server/audiobook'; + +test.describe('escapeFFMetadata', () => { + test('escapes special characters correctly', () => { + const input = 'Title with = ; # and backslash \\'; + // Expected: Equal -> \=, Semicolon -> \;, Hash -> \#, Backslash -> \\ + const expected = 'Title with \\= \\; \\# and backslash \\\\'; + expect(escapeFFMetadata(input)).toBe(expected); + }); + + test('normalizes newlines to spaces', () => { + const input = 'Title with\nnewline and\rreturn'; + const expected = 'Title with newline and return'; + expect(escapeFFMetadata(input)).toBe(expected); + }); + + test('handles mixed special characters and newlines', () => { + const input = 'Line1\nLine2=Value;Comment#'; + const expected = 'Line1 Line2\\=Value\\;Comment\\#'; + expect(escapeFFMetadata(input)).toBe(expected); + }); + + test('returns empty string as-is', () => { + expect(escapeFFMetadata('')).toBe(''); + }); + + test('returns safe string as-is', () => { + const input = 'Safe Title 123'; + expect(escapeFFMetadata(input)).toBe(input); + }); +}); diff --git a/tests/docstore_unit.spec.ts b/tests/unit/docstore.spec.ts similarity index 96% rename from tests/docstore_unit.spec.ts rename to tests/unit/docstore.spec.ts index ccd4879..55a57e7 100644 --- a/tests/docstore_unit.spec.ts +++ b/tests/unit/docstore.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { getMigratedDocumentFileName } from '@/lib/server/docstore'; +import { getMigratedDocumentFileName } from '../../src/lib/server/docstore'; test.describe('Docstore Filename Safety', () => { const id = 'a'.repeat(64); // Simulate sha256 hex ID From 409b25977c7c23c58a9640b49fc18b3f92b6a182 Mon Sep 17 00:00:00 2001 From: Richard R <me@richardr.dev> Date: Mon, 19 Jan 2026 17:45:55 -0700 Subject: [PATCH 12/12] tests: Add NLP text processing, audiobook chapter encoding/decoding, and SHA256 hashing testing. --- src/lib/server/audiobook.ts | 2 +- src/lib/sha256.ts | 2 +- tests/unit/audiobook.spec.ts | 67 ++++++++++++++++++++++++++- tests/unit/nlp.spec.ts | 88 ++++++++++++++++++++++++++++++++++++ tests/unit/sha256.spec.ts | 34 ++++++++++++++ 5 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 tests/unit/nlp.spec.ts create mode 100644 tests/unit/sha256.spec.ts diff --git a/src/lib/server/audiobook.ts b/src/lib/server/audiobook.ts index f32a2a5..86993b5 100644 --- a/src/lib/server/audiobook.ts +++ b/src/lib/server/audiobook.ts @@ -57,7 +57,7 @@ export function encodeChapterFileName(index: number, title: string, format: 'mp3 return `${oneBased}__${encodeURIComponent(safeTitle)}.${format}`; } -function decodeChapterFileName(fileName: string): { index: number; title: string; format: 'mp3' | 'm4b' } | null { +export function decodeChapterFileName(fileName: string): { index: number; title: string; format: 'mp3' | 'm4b' } | null { const match = /^(\d{1,6})__(.+)\.(mp3|m4b)$/i.exec(fileName); if (!match) return null; const oneBased = Number(match[1]); diff --git a/src/lib/sha256.ts b/src/lib/sha256.ts index d36f055..f4abc38 100644 --- a/src/lib/sha256.ts +++ b/src/lib/sha256.ts @@ -12,7 +12,7 @@ function rotr32(x: number, n: number): number { return ((x >>> n) | (x << (32 - n))) >>> 0; } -function sha256HexSoftwareFromBytes(bytes: Uint8Array): string { +export function sha256HexSoftwareFromBytes(bytes: Uint8Array): string { const K = new Uint32Array([ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, diff --git a/tests/unit/audiobook.spec.ts b/tests/unit/audiobook.spec.ts index 1d990ad..729de08 100644 --- a/tests/unit/audiobook.spec.ts +++ b/tests/unit/audiobook.spec.ts @@ -1,5 +1,11 @@ import { test, expect } from '@playwright/test'; -import { escapeFFMetadata } from '../../src/lib/server/audiobook'; +import { + escapeFFMetadata, + encodeChapterTitleTag, + decodeChapterTitleTag, + encodeChapterFileName, + decodeChapterFileName +} from '../../src/lib/server/audiobook'; test.describe('escapeFFMetadata', () => { test('escapes special characters correctly', () => { @@ -30,3 +36,62 @@ test.describe('escapeFFMetadata', () => { expect(escapeFFMetadata(input)).toBe(input); }); }); + +test.describe('Title Tags', () => { + test('encodeChapterTitleTag formats correctly', () => { + expect(encodeChapterTitleTag(0, 'Intro')).toBe('0001 - Intro'); + expect(encodeChapterTitleTag(9, 'Chapter Ten')).toBe('0010 - Chapter Ten'); + }); + + test('encodeChapterTitleTag sanitizes inputs', () => { + expect(encodeChapterTitleTag(0, 'Line\nBreak')).toBe('0001 - Line Break'); + }); + + test('decodeChapterTitleTag parses correctly', () => { + expect(decodeChapterTitleTag('0001 - Intro')).toEqual({ index: 0, title: 'Intro' }); + expect(decodeChapterTitleTag('10 - Chapter Ten')).toEqual({ index: 9, title: 'Chapter Ten' }); + }); + + test('decodeChapterTitleTag handles flexible separators', () => { + expect(decodeChapterTitleTag('1: Intro')).toEqual({ index: 0, title: 'Intro' }); + expect(decodeChapterTitleTag('1. Intro')).toEqual({ index: 0, title: 'Intro' }); + }); + + test('decodeChapterTitleTag returns null for invalid input', () => { + expect(decodeChapterTitleTag('Not a chapter')).toBeNull(); + expect(decodeChapterTitleTag('0 - Zero index invalid')).toBeNull(); + }); +}); + +test.describe('Chapter File Names', () => { + test('encodeChapterFileName formats correctly', () => { + expect(encodeChapterFileName(0, 'Intro', 'mp3')).toBe('0001__Intro.mp3'); + expect(encodeChapterFileName(1, 'Part 2', 'm4b')).toBe('0002__Part%202.m4b'); + }); + + test('encodeChapterFileName sanitizes dangerous characters', () => { + // slash should be replaced by space -> then encoded + expect(encodeChapterFileName(0, 'Ac/Dc', 'mp3')).toBe('0001__Ac%20Dc.mp3'); + }); + + test('decodeChapterFileName parses correctly', () => { + expect(decodeChapterFileName('0001__Intro.mp3')).toEqual({ index: 0, title: 'Intro', format: 'mp3' }); + expect(decodeChapterFileName('0002__Part%202.m4b')).toEqual({ index: 1, title: 'Part 2', format: 'm4b' }); + }); + + test('decodeChapterFileName handles standard filenames without double-underscore', () => { + // The regex requires double underscore: /^(\d{1,6})__(.+)\.(mp3|m4b)$/i + expect(decodeChapterFileName('0001-chapter.mp3')).toBeNull(); + }); + + test('round trip consistency', () => { + const index = 5; + const title = 'My Cool Chapter'; + const format = 'mp3'; + + const encoded = encodeChapterFileName(index, title, format); + const decoded = decodeChapterFileName(encoded); + + expect(decoded).toEqual({ index, title, format }); + }); +}); diff --git a/tests/unit/nlp.spec.ts b/tests/unit/nlp.spec.ts new file mode 100644 index 0000000..4bb5485 --- /dev/null +++ b/tests/unit/nlp.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test'; +import { + preprocessSentenceForAudio, + splitIntoSentences, + processTextWithMapping +} from '../../src/lib/nlp'; + +test.describe('preprocessSentenceForAudio', () => { + test('removes URLs', () => { + const input = 'Check out https://example.com/page for more info'; + const expected = 'Check out - (link to example.com) - for more info'; + expect(preprocessSentenceForAudio(input)).toBe(expected); + }); + + test('removes hyphenation', () => { + const input = 'This is a hyp- henated word'; + const expected = 'This is a hyphenated word'; + expect(preprocessSentenceForAudio(input)).toBe(expected); + }); + + test('removes asterisks', () => { + const input = 'This is *bold* text'; + const expected = 'This is bold text'; + expect(preprocessSentenceForAudio(input)).toBe(expected); + }); + + test('collapses whitespace', () => { + const input = 'Multiple spaces'; + const expected = 'Multiple spaces'; + expect(preprocessSentenceForAudio(input)).toBe(expected); + }); +}); + +test.describe('splitIntoSentences', () => { + test('groups short sentences into single block', () => { + const input = 'First sentence. Second sentence.'; + const result = splitIntoSentences(input); + expect(result).toHaveLength(1); + expect(result[0]).toBe('First sentence. Second sentence.'); + }); + + test('merges quoted dialogue (double quotes)', () => { + const input = 'He said, "This should be one block." and walked away.'; + const result = splitIntoSentences(input); + expect(result).toHaveLength(1); + expect(result[0]).toBe('He said, "This should be one block." and walked away.'); + }); + + test('merges quoted dialogue (curly quotes)', () => { + const input = 'She replied, “This also should be merged.” then smiled.'; + const result = splitIntoSentences(input); + expect(result).toHaveLength(1); + expect(result[0]).toBe('She replied, “This also should be merged.” then smiled.'); + }); + + test('respects max block length for long text', () => { + // MAX_BLOCK_LENGTH is 450 in nlp.ts + // We construct distinct sentences. + // If we make sentences short enough individually but long enough combined, + // they should be grouped until the limit is reached. + + const sentence = 'A'.repeat(100) + '.'; // 101 chars + // 4 sentences = 404 chars + 3 spaces = 407 chars (< 450). Should fit in one block. + // 5 sentences = 505 chars + 4 spaces = 509 chars (> 450). Should split. + + const input = Array(5).fill(sentence).join(' '); + const result = splitIntoSentences(input); + + expect(result.length).toBeGreaterThan(1); + // The first block should contain as many as possible + expect(result[0].length).toBeLessThanOrEqual(450); + }); +}); + +test.describe('processTextWithMapping', () => { + test('maps raw sentences to processed ones', () => { + const text = 'First (1). Second (2).'; + const { processedSentences, rawSentences, sentenceMapping } = processTextWithMapping(text); + + expect(processedSentences.length).toBeGreaterThan(0); + expect(rawSentences.length).toBeGreaterThan(0); + expect(sentenceMapping).toHaveLength(processedSentences.length); + + // Check structure of mapping + expect(sentenceMapping[0]).toHaveProperty('processedIndex'); + expect(sentenceMapping[0]).toHaveProperty('rawIndices'); + }); +}); diff --git a/tests/unit/sha256.spec.ts b/tests/unit/sha256.spec.ts new file mode 100644 index 0000000..030122a --- /dev/null +++ b/tests/unit/sha256.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test'; +import { + sha256HexSoftwareFromBytes, + sha256HexFromString +} from '../../src/lib/sha256'; + +test.describe('SHA256 Software Implementation', () => { + // Known test vectors + // "" -> e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + // "abc" -> ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + + test('hashes empty string correctly', () => { + const input = new Uint8Array([]); + const expected = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + expect(sha256HexSoftwareFromBytes(input)).toBe(expected); + }); + + test('hashes "abc" correctly', () => { + const input = new TextEncoder().encode('abc'); + const expected = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'; + expect(sha256HexSoftwareFromBytes(input)).toBe(expected); + }); + + test('matches WebCrypto/fallback output for long strings', async () => { + // Create a longer input + const text = 'a'.repeat(1000); + const input = new TextEncoder().encode(text); + + const software = sha256HexSoftwareFromBytes(input); + const automatic = await sha256HexFromString(text); + + expect(software).toBe(automatic); + }); +});