openreader/src/lib/dexie.ts
Richard Roberson 733b4180eb feat(tts): add word-by-word highlighting with whisper.cpp
- Introduce `/api/whisper` endpoint which uses `whisper.cpp` (via a `WHISPER_CPP_BIN` executable) and `ffmpeg` to generate word-level audio alignments from provided audio and text.
- Integrate word-level alignments into `TTSContext`, tracking the currently spoken word based on audio seek position and provided timestamps. Alignments are cached in-memory and fetched asynchronously.
- Add new configuration options (`pdfWordHighlightEnabled`, `epubWordHighlightEnabled`) to `ConfigContext` and `Dexie` for enabling/disabling the feature.
- Implement visual word highlighting in both `PDFViewer` and `EPUBViewer` by mapping TTS-aligned words to rendered text elements.
- Enhance `EPUBContext` and `PDFContext` with new `highlightWordIndex` and `clearWordHighlights` functions, utilizing fuzzy string matching (`cmpstr`) to robustly align spoken words with displayed text for accurate highlighting.
- Update `DocumentSettings` to include user-facing toggles for the new highlighting modes.
2025-11-21 19:22:54 -07:00

557 lines
17 KiB
TypeScript

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';
const DB_NAME = 'openreader-db';
// Managed via Dexie (version bumped from the original manual IndexedDB)
const DB_VERSION = 5;
const PDF_TABLE = 'pdf-documents' as const;
const EPUB_TABLE = 'epub-documents' as const;
const HTML_TABLE = 'html-documents' as const;
const CONFIG_TABLE = 'config' as const;
const APP_CONFIG_TABLE = 'app-config' as const;
const LAST_LOCATION_TABLE = 'last-locations' as const;
export interface LastLocationRow {
docId: string;
location: string;
}
export interface ConfigRow {
key: string;
value: string;
}
type OpenReaderDB = Dexie & {
[PDF_TABLE]: EntityTable<PDFDocument, 'id'>;
[EPUB_TABLE]: EntityTable<EPUBDocument, 'id'>;
[HTML_TABLE]: EntityTable<HTMLDocument, 'id'>;
[CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>;
[APP_CONFIG_TABLE]: EntityTable<AppConfigRow, 'id'>;
[LAST_LOCATION_TABLE]: EntityTable<LastLocationRow, 'docId'>;
};
export const db = new Dexie(DB_NAME) as OpenReaderDB;
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
openai: 'https://api.openai.com/v1',
deepinfra: 'https://api.deepinfra.com/v1/openai',
'custom-openai': '',
};
type RawConfigMap = Record<string, string | undefined>;
function inferProviderAndBaseUrl(raw: RawConfigMap): { provider: string; baseUrl: string } {
const cachedApiKey = raw.apiKey;
const cachedBaseUrl = raw.baseUrl;
let inferredProvider = raw.ttsProvider || '';
if (!isDev && !raw.ttsProvider) {
inferredProvider = 'deepinfra';
} else if (!inferredProvider) {
if (cachedBaseUrl) {
const baseUrlLower = cachedBaseUrl.toLowerCase();
if (baseUrlLower.includes('deepinfra.com')) {
inferredProvider = 'deepinfra';
} else if (baseUrlLower.includes('openai.com')) {
inferredProvider = 'openai';
} else if (
baseUrlLower.includes('localhost') ||
baseUrlLower.includes('127.0.0.1') ||
baseUrlLower.includes('internal')
) {
inferredProvider = 'custom-openai';
} else {
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
}
} else {
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
}
}
let baseUrl = cachedBaseUrl || '';
if (!baseUrl) {
if (inferredProvider === 'openai') {
baseUrl = PROVIDER_DEFAULT_BASE_URL.openai;
} else if (inferredProvider === 'deepinfra') {
baseUrl = PROVIDER_DEFAULT_BASE_URL.deepinfra;
} else {
baseUrl = PROVIDER_DEFAULT_BASE_URL['custom-openai'];
}
}
return { provider: inferredProvider, baseUrl };
}
function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
const { provider, baseUrl } = inferProviderAndBaseUrl(raw);
let savedVoices: SavedVoices = {};
if (raw.savedVoices) {
try {
savedVoices = JSON.parse(raw.savedVoices) as SavedVoices;
} catch (error) {
console.error('Error parsing savedVoices during migration:', error);
}
}
let documentListState: DocumentListState = APP_CONFIG_DEFAULTS.documentListState;
if (raw.documentListState) {
try {
documentListState = JSON.parse(raw.documentListState) as DocumentListState;
} catch (error) {
console.error('Error parsing documentListState during migration:', error);
}
}
const config: AppConfigRow = {
id: 'singleton',
...APP_CONFIG_DEFAULTS,
apiKey: raw.apiKey ?? APP_CONFIG_DEFAULTS.apiKey,
baseUrl,
viewType: (raw.viewType as ViewType) || APP_CONFIG_DEFAULTS.viewType,
voiceSpeed: raw.voiceSpeed ? parseFloat(raw.voiceSpeed) : APP_CONFIG_DEFAULTS.voiceSpeed,
audioPlayerSpeed: raw.audioPlayerSpeed ? parseFloat(raw.audioPlayerSpeed) : APP_CONFIG_DEFAULTS.audioPlayerSpeed,
voice: '',
skipBlank: raw.skipBlank === 'false' ? false : APP_CONFIG_DEFAULTS.skipBlank,
epubTheme: raw.epubTheme === 'true',
smartSentenceSplitting:
raw.smartSentenceSplitting === 'false' ? false : APP_CONFIG_DEFAULTS.smartSentenceSplitting,
headerMargin: raw.headerMargin ? parseFloat(raw.headerMargin) : APP_CONFIG_DEFAULTS.headerMargin,
footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin,
leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin,
rightMargin: raw.rightMargin ? parseFloat(raw.rightMargin) : APP_CONFIG_DEFAULTS.rightMargin,
ttsProvider: provider || APP_CONFIG_DEFAULTS.ttsProvider,
ttsModel:
raw.ttsModel ||
(provider === 'openai'
? 'tts-1'
: provider === 'deepinfra'
? 'hexgrad/Kokoro-82M'
: APP_CONFIG_DEFAULTS.ttsModel),
ttsInstructions: raw.ttsInstructions ?? APP_CONFIG_DEFAULTS.ttsInstructions,
savedVoices,
pdfHighlightEnabled:
raw.pdfHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.pdfHighlightEnabled,
pdfWordHighlightEnabled:
raw.pdfWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.pdfWordHighlightEnabled,
epubHighlightEnabled:
raw.epubHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubHighlightEnabled,
epubWordHighlightEnabled:
raw.epubWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubWordHighlightEnabled,
firstVisit: raw.firstVisit === 'true',
documentListState,
};
const voiceKey = `${config.ttsProvider}:${config.ttsModel}`;
config.voice = config.savedVoices[voiceKey] || '';
return config;
}
// Version 5: introduce app-config and last-locations tables, 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',
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId',
[HTML_TABLE]: 'id, type, name, lastModified, size, folderId',
[APP_CONFIG_TABLE]: 'id',
[LAST_LOCATION_TABLE]: 'docId',
// `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,
}).upgrade(async (trans) => {
const appConfig = await trans.table<AppConfigRow, string>(APP_CONFIG_TABLE).get('singleton');
if (appConfig) {
return;
}
const configRows = await trans.table<ConfigRow, string>(CONFIG_TABLE).toArray();
const raw: RawConfigMap = {};
for (const row of configRows) {
raw[row.key] = row.value;
}
const built = buildAppConfigFromRaw(raw);
await trans.table<AppConfigRow, string>(APP_CONFIG_TABLE).put(built);
// Migrate any legacy lastLocation_* keys into the dedicated last-locations table.
const locationTable = trans.table<LastLocationRow, string>(LAST_LOCATION_TABLE);
for (const row of configRows) {
if (row.key.startsWith('lastLocation_')) {
const docId = row.key.substring('lastLocation_'.length);
await locationTable.put({ docId, location: row.value });
}
}
});
let dbOpenPromise: Promise<void> | null = null;
export async function initDB(): Promise<void> {
if (dbOpenPromise) {
return dbOpenPromise;
}
dbOpenPromise = (async () => {
try {
console.log('Opening Dexie database...');
await db.open();
console.log('Dexie database opened successfully');
} catch (error) {
console.error('Dexie initialization error:', error);
dbOpenPromise = null;
throw error;
}
})();
return dbOpenPromise;
}
async function withDB<T>(operation: () => Promise<T>): Promise<T> {
await initDB();
return operation();
}
// PDF document helpers
export async function addPdfDocument(document: PDFDocument): Promise<void> {
await withDB(async () => {
console.log('Adding PDF document via Dexie:', document.name);
await db[PDF_TABLE].put(document);
});
}
export async function getPdfDocument(id: string): Promise<PDFDocument | undefined> {
return withDB(async () => {
console.log('Fetching PDF document via Dexie:', id);
return db[PDF_TABLE].get(id);
});
}
export async function getAllPdfDocuments(): Promise<PDFDocument[]> {
return withDB(async () => {
console.log('Fetching all PDF documents via Dexie');
return db[PDF_TABLE].toArray();
});
}
export async function removePdfDocument(id: string): Promise<void> {
await withDB(async () => {
console.log('Removing PDF document via Dexie:', id);
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);
});
});
}
export async function clearPdfDocuments(): Promise<void> {
await withDB(async () => {
console.log('Clearing all PDF documents via Dexie');
await db[PDF_TABLE].clear();
});
}
// EPUB document helpers
export async function addEpubDocument(document: EPUBDocument): Promise<void> {
await withDB(async () => {
if (document.data.byteLength === 0) {
throw new Error('Cannot store empty ArrayBuffer');
}
console.log('Adding EPUB document via Dexie:', {
name: document.name,
size: document.size,
actualSize: document.data.byteLength,
});
await db[EPUB_TABLE].put(document);
});
}
export async function getEpubDocument(id: string): Promise<EPUBDocument | undefined> {
return withDB(async () => {
console.log('Fetching EPUB document via Dexie:', id);
return db[EPUB_TABLE].get(id);
});
}
export async function getAllEpubDocuments(): Promise<EPUBDocument[]> {
return withDB(async () => {
console.log('Fetching all EPUB documents via Dexie');
return db[EPUB_TABLE].toArray();
});
}
export async function removeEpubDocument(id: string): Promise<void> {
await withDB(async () => {
console.log('Removing EPUB document via Dexie:', id);
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);
});
});
}
export async function clearEpubDocuments(): Promise<void> {
await withDB(async () => {
console.log('Clearing all EPUB documents via Dexie');
await db[EPUB_TABLE].clear();
});
}
// HTML / text document helpers
export async function addHtmlDocument(document: HTMLDocument): Promise<void> {
await withDB(async () => {
console.log('Adding HTML document via Dexie:', document.name);
await db[HTML_TABLE].put(document);
});
}
export async function getHtmlDocument(id: string): Promise<HTMLDocument | undefined> {
return withDB(async () => {
console.log('Fetching HTML document via Dexie:', id);
return db[HTML_TABLE].get(id);
});
}
export async function getAllHtmlDocuments(): Promise<HTMLDocument[]> {
return withDB(async () => {
console.log('Fetching all HTML documents via Dexie');
return db[HTML_TABLE].toArray();
});
}
export async function removeHtmlDocument(id: string): Promise<void> {
await withDB(async () => {
console.log('Removing HTML document via Dexie:', id);
await db[HTML_TABLE].delete(id);
});
}
export async function clearHtmlDocuments(): Promise<void> {
await withDB(async () => {
console.log('Clearing all HTML documents via Dexie');
await db[HTML_TABLE].clear();
});
}
export async function getAppConfig(): Promise<AppConfigRow | null> {
return withDB(async () => {
const row = await db[APP_CONFIG_TABLE].get('singleton');
return row ?? null;
});
}
export async function updateAppConfig(partial: Partial<AppConfigRow>): Promise<void> {
await withDB(async () => {
const table = db[APP_CONFIG_TABLE];
const existing = await table.get('singleton');
if (!existing) {
await table.put({
id: 'singleton',
...APP_CONFIG_DEFAULTS,
...partial,
});
} else {
await table.update('singleton', partial);
}
});
}
// Document list state helpers
export async function saveDocumentListState(state: DocumentListState): Promise<void> {
await updateAppConfig({ documentListState: state });
}
export async function getDocumentListState(): Promise<DocumentListState | null> {
const config = await getAppConfig();
if (!config || !config.documentListState) return null;
return config.documentListState;
}
// Last-location helpers (used by TTS and readers)
export async function getLastDocumentLocation(docId: string): Promise<string | null> {
return withDB(async () => {
const row = await db[LAST_LOCATION_TABLE].get(docId);
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 });
});
}
// First-visit helpers (used for onboarding/Settings modal)
export async function getFirstVisit(): Promise<boolean> {
const config = await getAppConfig();
return config?.firstVisit ?? false;
}
export async function setFirstVisit(value: boolean): Promise<void> {
await updateAppConfig({ firstVisit: value });
}
// Sync helpers (server round-trip)
export async function syncDocumentsToServer(
onProgress?: (progress: number, status?: string) => void,
signal?: AbortSignal,
): Promise<{ lastSync: number }> {
const pdfDocs = await getAllPdfDocuments();
const epubDocs = await getAllEpubDocuments();
const htmlDocs = await getAllHtmlDocuments();
const documents: SyncedDocument[] = [];
const totalDocs = pdfDocs.length + epubDocs.length + htmlDocs.length;
let processedDocs = 0;
for (const doc of pdfDocs) {
documents.push({
...doc,
type: 'pdf',
data: Array.from(new Uint8Array(doc.data)),
});
processedDocs++;
if (onProgress) {
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
}
}
for (const doc of epubDocs) {
documents.push({
...doc,
type: 'epub',
data: Array.from(new Uint8Array(doc.data)),
});
processedDocs++;
if (onProgress) {
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
}
}
const encoder = new TextEncoder();
for (const doc of htmlDocs) {
const encoded = encoder.encode(doc.data);
documents.push({
...doc,
type: 'html',
data: Array.from(encoded),
});
processedDocs++;
if (onProgress) {
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
}
}
if (onProgress) {
onProgress(50, 'Uploading to server...');
}
const response = await fetch('/api/documents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documents }),
signal,
});
if (!response.ok) {
throw new Error('Failed to sync documents to server');
}
if (onProgress) {
onProgress(100, 'Upload complete!');
}
return { lastSync: Date.now() };
}
export async function loadDocumentsFromServer(
onProgress?: (progress: number, status?: string) => void,
signal?: AbortSignal,
): Promise<{ lastSync: number }> {
if (onProgress) {
onProgress(10, 'Starting download...');
}
const response = await fetch('/api/documents', { signal });
if (!response.ok) {
throw new Error('Failed to fetch documents from server');
}
if (onProgress) {
onProgress(30, 'Download complete');
}
const { documents } = (await response.json()) as { documents: SyncedDocument[] };
if (onProgress) {
onProgress(40, 'Parsing documents...');
}
const textDecoder = new TextDecoder();
for (let i = 0; i < documents.length; i++) {
const doc = documents[i];
if (doc.type === 'pdf') {
const uint8Array = new Uint8Array(doc.data);
const documentData: PDFDocument = {
id: doc.id,
type: 'pdf',
name: doc.name,
size: doc.size,
lastModified: doc.lastModified,
data: uint8Array.buffer,
};
await addPdfDocument(documentData);
} else if (doc.type === 'epub') {
const uint8Array = new Uint8Array(doc.data);
const documentData: EPUBDocument = {
id: doc.id,
type: 'epub',
name: doc.name,
size: doc.size,
lastModified: doc.lastModified,
data: uint8Array.buffer,
};
await addEpubDocument(documentData);
} else if (doc.type === 'html') {
const uint8Array = new Uint8Array(doc.data);
const decoded = textDecoder.decode(uint8Array);
const documentData: HTMLDocument = {
id: doc.id,
type: 'html',
name: doc.name,
size: doc.size,
lastModified: doc.lastModified,
data: decoded,
};
await addHtmlDocument(documentData);
} else {
console.warn(`Unknown document type: ${doc.type}`);
}
if (onProgress) {
onProgress(40 + ((i + 1) / documents.length) * 50, `Processing document ${i + 1}/${documents.length}...`);
}
}
if (onProgress) {
onProgress(100, 'Load complete!');
}
return { lastSync: Date.now() };
}