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; [EPUB_TABLE]: EntityTable; [HTML_TABLE]: EntityTable; [CONFIG_TABLE]: EntityTable; [APP_CONFIG_TABLE]: EntityTable; [LAST_LOCATION_TABLE]: EntityTable; }; 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 = { openai: 'https://api.openai.com/v1', deepinfra: 'https://api.deepinfra.com/v1/openai', 'custom-openai': '', }; type RawConfigMap = Record; 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(APP_CONFIG_TABLE).get('singleton'); if (appConfig) { return; } const configRows = await trans.table(CONFIG_TABLE).toArray(); const raw: RawConfigMap = {}; for (const row of configRows) { raw[row.key] = row.value; } const built = buildAppConfigFromRaw(raw); await trans.table(APP_CONFIG_TABLE).put(built); // Migrate any legacy lastLocation_* keys into the dedicated last-locations table. const locationTable = trans.table(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 | null = null; export async function initDB(): Promise { if (dbOpenPromise) { return dbOpenPromise; } dbOpenPromise = (async () => { try { console.log('Opening Dexie database...'); await db.open(); console.log('Dexie database opened successfully'); } catch (error) { console.error('Dexie initialization error:', error); dbOpenPromise = null; throw error; } })(); return dbOpenPromise; } async function withDB(operation: () => Promise): Promise { await initDB(); return operation(); } // PDF document helpers export async function addPdfDocument(document: PDFDocument): Promise { await withDB(async () => { console.log('Adding PDF document via Dexie:', document.name); await db[PDF_TABLE].put(document); }); } export async function getPdfDocument(id: string): Promise { return withDB(async () => { console.log('Fetching PDF document via Dexie:', id); return db[PDF_TABLE].get(id); }); } export async function getAllPdfDocuments(): Promise { return withDB(async () => { console.log('Fetching all PDF documents via Dexie'); return db[PDF_TABLE].toArray(); }); } export async function removePdfDocument(id: string): Promise { await withDB(async () => { console.log('Removing PDF document via Dexie:', id); 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 { await withDB(async () => { console.log('Clearing all PDF documents via Dexie'); await db[PDF_TABLE].clear(); }); } // EPUB document helpers export async function addEpubDocument(document: EPUBDocument): Promise { await withDB(async () => { if (document.data.byteLength === 0) { throw new Error('Cannot store empty ArrayBuffer'); } console.log('Adding EPUB document via Dexie:', { name: document.name, size: document.size, actualSize: document.data.byteLength, }); await db[EPUB_TABLE].put(document); }); } export async function getEpubDocument(id: string): Promise { return withDB(async () => { console.log('Fetching EPUB document via Dexie:', id); return db[EPUB_TABLE].get(id); }); } export async function getAllEpubDocuments(): Promise { return withDB(async () => { console.log('Fetching all EPUB documents via Dexie'); return db[EPUB_TABLE].toArray(); }); } export async function removeEpubDocument(id: string): Promise { await withDB(async () => { console.log('Removing EPUB document via Dexie:', id); 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 { await withDB(async () => { console.log('Clearing all EPUB documents via Dexie'); await db[EPUB_TABLE].clear(); }); } // HTML / text document helpers export async function addHtmlDocument(document: HTMLDocument): Promise { await withDB(async () => { console.log('Adding HTML document via Dexie:', document.name); await db[HTML_TABLE].put(document); }); } export async function getHtmlDocument(id: string): Promise { return withDB(async () => { console.log('Fetching HTML document via Dexie:', id); return db[HTML_TABLE].get(id); }); } export async function getAllHtmlDocuments(): Promise { return withDB(async () => { console.log('Fetching all HTML documents via Dexie'); return db[HTML_TABLE].toArray(); }); } export async function removeHtmlDocument(id: string): Promise { await withDB(async () => { console.log('Removing HTML document via Dexie:', id); await db[HTML_TABLE].delete(id); }); } export async function clearHtmlDocuments(): Promise { await withDB(async () => { console.log('Clearing all HTML documents via Dexie'); await db[HTML_TABLE].clear(); }); } export async function getAppConfig(): Promise { return withDB(async () => { const row = await db[APP_CONFIG_TABLE].get('singleton'); return row ?? null; }); } export async function updateAppConfig(partial: Partial): Promise { 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 { await updateAppConfig({ documentListState: state }); } export async function getDocumentListState(): Promise { 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 { 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 { await withDB(async () => { await db[LAST_LOCATION_TABLE].put({ docId, location }); }); } // First-visit helpers (used for onboarding/Settings modal) export async function getFirstVisit(): Promise { const config = await getAppConfig(); return config?.firstVisit ?? false; } export async function setFirstVisit(value: boolean): Promise { 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() }; }