diff --git a/playwright.config.ts b/playwright.config.ts index f5ad03b..5d20f5f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? '100%' : '50%', + // workers: '50%', /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index fa1f78f..42e5c4a 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, setItem, getItem } from '@/utils/dexie'; +import { syncDocumentsToServer, loadDocumentsFromServer, getFirstVisit, setFirstVisit } from '@/utils/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; @@ -122,9 +122,9 @@ export function SettingsModal() { // set firstVisit on initial load const checkFirstVist = useCallback(async () => { if (!isDev) return; - const firstVisit = await getItem('firstVisit'); - if (firstVisit == null) { - await setItem('firstVisit', 'true'); + const firstVisit = await getFirstVisit(); + if (!firstVisit) { + await setFirstVisit(true); setIsOpen(true); } }, [setIsOpen]); diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index cb427c6..ac5be7d 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -1,37 +1,13 @@ 'use client'; -import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; -import { getItem, setItem, removeItem, initDB } from '@/utils/dexie'; - -const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; - -/** Represents the possible view types for document display */ -export type ViewType = 'single' | 'dual' | 'scroll'; - -/** Saved voice configurations per provider-model */ -type SavedVoices = Record; +import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { db, initDB, updateAppConfig } from '@/utils/dexie'; +import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/appConfig'; +export type { ViewType } from '@/types/appConfig'; /** Configuration values for the application */ -type ConfigValues = { - apiKey: string; - baseUrl: string; - viewType: ViewType; - voiceSpeed: number; - audioPlayerSpeed: number; - voice: string; - skipBlank: boolean; - epubTheme: boolean; - headerMargin: number; - footerMargin: number; - leftMargin: number; - rightMargin: number; - ttsProvider: string; - ttsModel: string; - ttsInstructions: string; - savedVoices: SavedVoices; - smartSentenceSplitting: boolean; - pdfHighlightEnabled: boolean; -}; +type ConfigValues = Omit; /** Interface defining the configuration context shape and functionality */ interface ConfigContextType { @@ -68,26 +44,6 @@ const ConfigContext = createContext(undefined); * @param {ReactNode} props.children - Child components to be wrapped by the provider */ export function ConfigProvider({ children }: { children: ReactNode }) { - // Config state - const [apiKey, setApiKey] = useState(''); - const [baseUrl, setBaseUrl] = useState(''); - const [viewType, setViewType] = useState('single'); - const [voiceSpeed, setVoiceSpeed] = useState(1); - const [audioPlayerSpeed, setAudioPlayerSpeed] = useState(1); - const [voice, setVoice] = useState('af_sarah'); - const [skipBlank, setSkipBlank] = useState(true); - const [epubTheme, setEpubTheme] = useState(false); - const [smartSentenceSplitting, setSmartSentenceSplitting] = useState(true); - const [headerMargin, setHeaderMargin] = useState(0.0); - const [footerMargin, setFooterMargin] = useState(0.0); - const [leftMargin, setLeftMargin] = useState(0.0); - const [rightMargin, setRightMargin] = useState(0.0); - const [ttsProvider, setTTSProvider] = useState('custom-openai'); - const [ttsModel, setTTSModel] = useState('kokoro'); - const [ttsInstructions, setTTSInstructions] = useState(''); - const [savedVoices, setSavedVoices] = useState({}); - const [pdfHighlightEnabled, setPdfHighlightEnabled] = useState(true); - const [isLoading, setIsLoading] = useState(true); const [isDBReady, setIsDBReady] = useState(false); @@ -100,164 +56,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) { setIsLoading(true); await initDB(); setIsDBReady(true); - - // Load config from IndexedDB - const cachedApiKey = await getItem('apiKey'); - const cachedBaseUrl = await getItem('baseUrl'); - const cachedViewType = await getItem('viewType'); - const cachedVoiceSpeed = await getItem('voiceSpeed'); - const cachedAudioPlayerSpeed = await getItem('audioPlayerSpeed'); - const cachedSkipBlank = await getItem('skipBlank'); - const cachedEpubTheme = await getItem('epubTheme'); - const cachedSmartSentenceSplitting = await getItem('smartSentenceSplitting'); - const cachedHeaderMargin = await getItem('headerMargin'); - const cachedFooterMargin = await getItem('footerMargin'); - const cachedLeftMargin = await getItem('leftMargin'); - const cachedRightMargin = await getItem('rightMargin'); - const cachedTTSProvider = await getItem('ttsProvider'); - const cachedTTSModel = await getItem('ttsModel'); - const cachedTTSInstructions = await getItem('ttsInstructions'); - const cachedSavedVoices = await getItem('savedVoices'); - const cachedPdfHighlightEnabled = await getItem('pdfHighlightEnabled'); - - // Migration logic: infer provider and baseUrl for returning users - let inferredProvider = cachedTTSProvider || ''; - let inferredBaseUrl = cachedBaseUrl || ''; - - // In production mode, force deepinfra provider if not already set - if (!isDev && !cachedTTSProvider) { - 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 { - // Unknown host: fall back based on presence of API key - inferredProvider = cachedApiKey ? 'openai' : 'custom-openai'; - } - } else { - // No provider stored and no baseUrl stored - // If there is an API key and no base URL -> assume OpenAI - // If empty with no API key -> default to custom - inferredProvider = cachedApiKey ? 'openai' : 'custom-openai'; - } - } - - // If baseUrl is missing, set a safe default based on the inferred provider - if (!inferredBaseUrl) { - if (inferredProvider === 'openai') { - inferredBaseUrl = 'https://api.openai.com/v1'; - } else if (inferredProvider === 'deepinfra') { - inferredBaseUrl = 'https://api.deepinfra.com/v1/openai'; - } else { - inferredBaseUrl = ''; - } - } - - // Only set API key and base URL if they were explicitly saved by the user - if (cachedApiKey) { - console.log('Using cached API key'); - setApiKey(cachedApiKey); - } - if (cachedBaseUrl) { - console.log('Using cached base URL'); - setBaseUrl(cachedBaseUrl); - } else if (inferredBaseUrl) { - // Migration: no stored baseUrl, pick a safe default from provider inference - console.log('Setting default base URL from inferred provider', inferredBaseUrl); - setBaseUrl(inferredBaseUrl); - await setItem('baseUrl', inferredBaseUrl); - } - - // Parse savedVoices - let parsedSavedVoices: SavedVoices = {}; - if (cachedSavedVoices) { - try { - parsedSavedVoices = JSON.parse(cachedSavedVoices); - } catch (error) { - console.error('Error parsing savedVoices:', error); - } - } - setSavedVoices(parsedSavedVoices); - - // Set the other values with defaults - setViewType((cachedViewType || 'single') as ViewType); - setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1')); - setAudioPlayerSpeed(parseFloat(cachedAudioPlayerSpeed || '1')); - setSkipBlank(cachedSkipBlank === 'false' ? false : true); - setEpubTheme(cachedEpubTheme === 'true'); - setSmartSentenceSplitting(cachedSmartSentenceSplitting === 'false' ? false : true); - setHeaderMargin(parseFloat(cachedHeaderMargin || '0.07')); - setFooterMargin(parseFloat(cachedFooterMargin || '0.07')); - setLeftMargin(parseFloat(cachedLeftMargin || '0.07')); - setRightMargin(parseFloat(cachedRightMargin || '0.07')); - setTTSProvider(inferredProvider || 'custom-openai'); - const finalModel = cachedTTSModel || (inferredProvider === 'openai' ? 'tts-1' : inferredProvider === 'deepinfra' ? 'hexgrad/Kokoro-82M' : 'kokoro'); - setTTSModel(finalModel); - setTTSInstructions(cachedTTSInstructions || ''); - setPdfHighlightEnabled(cachedPdfHighlightEnabled === 'false' ? false : true); - - // Restore voice for current provider-model if available in savedVoices - const voiceKey = getVoiceKey(inferredProvider || 'custom-openai', finalModel); - const restoredVoice = parsedSavedVoices[voiceKey] || ''; - setVoice(restoredVoice); - - // Only save non-sensitive settings by default - if (!cachedViewType) { - await setItem('viewType', 'single'); - } - if (cachedSkipBlank === null) { - await setItem('skipBlank', 'true'); - } - if (cachedEpubTheme === null) { - await setItem('epubTheme', 'false'); - } - if (cachedSmartSentenceSplitting === null) { - await setItem('smartSentenceSplitting', 'true'); - } - if (cachedHeaderMargin === null) await setItem('headerMargin', '0.0'); - if (cachedFooterMargin === null) await setItem('footerMargin', '0.0'); - if (cachedLeftMargin === null) await setItem('leftMargin', '0.0'); - if (cachedRightMargin === null) await setItem('rightMargin', '0.0'); - if (cachedTTSProvider === null && inferredProvider) { - await setItem('ttsProvider', inferredProvider); - } else if (cachedTTSProvider === null) { - await setItem('ttsProvider', 'custom-openai'); - } - if (cachedTTSModel === null) { - const defaultModel = inferredProvider === 'openai' ? 'tts-1' : inferredProvider === 'deepinfra' ? 'hexgrad/Kokoro-82M' : 'kokoro'; - await setItem('ttsModel', defaultModel); - } - if (cachedTTSInstructions === null) { - await setItem('ttsInstructions', ''); - } - if (!cachedVoiceSpeed) { - await setItem('voiceSpeed', '1'); - } - if (!cachedAudioPlayerSpeed) { - await setItem('audioPlayerSpeed', '1'); - } - if (!cachedSavedVoices) { - await setItem('savedVoices', JSON.stringify({})); - } - // Always ensure voice is not stored standalone - only in savedVoices - await removeItem('voice'); - - if (cachedPdfHighlightEnabled === null) { - await setItem('pdfHighlightEnabled', 'true'); - } - } catch (error) { - console.error('Error initializing:', error); + console.error('Error initializing Dexie:', error); } finally { setIsLoading(false); } @@ -266,6 +66,45 @@ export function ConfigProvider({ children }: { children: ReactNode }) { initializeDB(); }, []); + const appConfig = useLiveQuery( + async () => { + if (!isDBReady) return null; + const row = await db['app-config'].get('singleton'); + return row ?? null; + }, + [isDBReady], + null, + ); + + const config: ConfigValues | null = useMemo(() => { + if (!appConfig) return null; + const { id, ...rest } = appConfig; + void id; + return rest; + }, [appConfig]); + + // Destructure for convenience and to match context shape + const { + apiKey, + baseUrl, + viewType, + voiceSpeed, + audioPlayerSpeed, + voice, + skipBlank, + epubTheme, + headerMargin, + footerMargin, + leftMargin, + rightMargin, + ttsProvider, + ttsModel, + ttsInstructions, + savedVoices, + smartSentenceSplitting, + pdfHighlightEnabled, + } = config || APP_CONFIG_DEFAULTS; + /** * Updates multiple configuration values simultaneously * Only saves API credentials if they are explicitly set @@ -273,27 +112,14 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => { try { setIsLoading(true); - if (newConfig.apiKey !== undefined && newConfig.apiKey !== '') { - // Only save API key to IndexedDB if it's different from env default - await setItem('apiKey', newConfig.apiKey!); - setApiKey(newConfig.apiKey!); + const updates: Partial = {}; + if (newConfig.apiKey !== undefined) { + updates.apiKey = newConfig.apiKey; } - if (newConfig.baseUrl !== undefined && newConfig.baseUrl !== '') { - // Only save base URL to IndexedDB if it's different from env default - await setItem('baseUrl', newConfig.baseUrl!); - setBaseUrl(newConfig.baseUrl!); + if (newConfig.baseUrl !== undefined) { + updates.baseUrl = newConfig.baseUrl; } - - // Delete completely if '' is passed - if (newConfig.apiKey === '') { - await removeItem('apiKey'); - setApiKey(''); - } - if (newConfig.baseUrl === '') { - await removeItem('baseUrl'); - setBaseUrl(''); - } - + await updateAppConfig(updates); } catch (error) { console.error('Error updating config:', error); throw error; @@ -315,86 +141,35 @@ export function ConfigProvider({ children }: { children: ReactNode }) { if (key === 'voice') { const voiceKey = getVoiceKey(ttsProvider, ttsModel); const updatedSavedVoices = { ...savedVoices, [voiceKey]: value as string }; - setSavedVoices(updatedSavedVoices); - await setItem('savedVoices', JSON.stringify(updatedSavedVoices)); - setVoice(value as string); + await updateAppConfig({ + savedVoices: updatedSavedVoices, + voice: value as string, + }); } // Special handling for provider/model changes - restore saved voice if available else if (key === 'ttsProvider' || key === 'ttsModel') { const newProvider = key === 'ttsProvider' ? (value as string) : ttsProvider; const newModel = key === 'ttsModel' ? (value as string) : ttsModel; const voiceKey = getVoiceKey(newProvider, newModel); - - // Update provider or model - await setItem(key, value.toString()); - if (key === 'ttsProvider') { - setTTSProvider(value as string); - } else { - setTTSModel(value as string); - } - - // Restore voice for this provider-model combination if it exists - const restoredVoice = savedVoices[voiceKey]; - if (restoredVoice) { - setVoice(restoredVoice); - } else { - // Clear voice so TTSContext will use first available - setVoice(''); - } + const restoredVoice = savedVoices[voiceKey] || ''; + await updateAppConfig({ + [key]: value as ConfigValues[keyof ConfigValues], + voice: restoredVoice, + } as Partial); } else if (key === 'savedVoices') { - setSavedVoices(value as SavedVoices); - await setItem('savedVoices', JSON.stringify(value)); + const newSavedVoices = value as SavedVoices; + await updateAppConfig({ + savedVoices: newSavedVoices, + }); } else { - await setItem(key, value.toString()); - switch (key) { - case 'apiKey': - setApiKey(value as string); - break; - case 'baseUrl': - setBaseUrl(value as string); - break; - case 'viewType': - setViewType(value as ViewType); - break; - case 'voiceSpeed': - setVoiceSpeed(value as number); - break; - case 'audioPlayerSpeed': - setAudioPlayerSpeed(value as number); - break; - case 'skipBlank': - setSkipBlank(value as boolean); - break; - case 'epubTheme': - setEpubTheme(value as boolean); - break; - case 'smartSentenceSplitting': - setSmartSentenceSplitting(value as boolean); - break; - case 'headerMargin': - setHeaderMargin(value as number); - break; - case 'footerMargin': - setFooterMargin(value as number); - break; - case 'leftMargin': - setLeftMargin(value as number); - break; - case 'rightMargin': - setRightMargin(value as number); - break; - case 'ttsInstructions': - setTTSInstructions(value as string); - break; - case 'pdfHighlightEnabled': - setPdfHighlightEnabled(value as boolean); - break; - } + await updateAppConfig({ + [key]: value as ConfigValues[keyof ConfigValues], + } as Partial); } } catch (error) { - console.error(`Error updating config key ${key}:`, error); + console.error(`Error updating config key ${String(key)}:`, error); throw error; } finally { setIsLoading(false); diff --git a/src/types/appConfig.ts b/src/types/appConfig.ts new file mode 100644 index 0000000..c34fb3b --- /dev/null +++ b/src/types/appConfig.ts @@ -0,0 +1,61 @@ +import type { DocumentListState } from '@/types/documents'; + +export type ViewType = 'single' | 'dual' | 'scroll'; + +export type SavedVoices = Record; + +export interface AppConfigDefaults { + apiKey: string; + baseUrl: string; + viewType: ViewType; + voiceSpeed: number; + audioPlayerSpeed: number; + voice: string; + skipBlank: boolean; + epubTheme: boolean; + headerMargin: number; + footerMargin: number; + leftMargin: number; + rightMargin: number; + ttsProvider: string; + ttsModel: string; + ttsInstructions: string; + savedVoices: SavedVoices; + smartSentenceSplitting: boolean; + pdfHighlightEnabled: boolean; + firstVisit: boolean; + documentListState: DocumentListState; +} + +export const APP_CONFIG_DEFAULTS: AppConfigDefaults = { + apiKey: '', + baseUrl: '', + viewType: 'single', + voiceSpeed: 1, + audioPlayerSpeed: 1, + voice: '', + skipBlank: true, + epubTheme: false, + headerMargin: 0, + footerMargin: 0, + leftMargin: 0, + rightMargin: 0, + ttsProvider: 'custom-openai', + ttsModel: 'kokoro', + ttsInstructions: '', + savedVoices: {}, + smartSentenceSplitting: true, + pdfHighlightEnabled: true, + firstVisit: false, + documentListState: { + sortBy: 'name', + sortDirection: 'asc', + folders: [], + collapsedFolders: [], + showHint: true, + }, +}; + +export interface AppConfigRow extends AppConfigDefaults { + id: string; +} diff --git a/src/utils/dexie.ts b/src/utils/dexie.ts index ab97169..8eb1554 100644 --- a/src/utils/dexie.ts +++ b/src/utils/dexie.ts @@ -1,20 +1,22 @@ import Dexie, { type EntityTable } from 'dexie'; -import { - PDFDocument, - EPUBDocument, - HTMLDocument, - DocumentListState, - SyncedDocument, -} from '@/types/documents'; +import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/appConfig'; +import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents'; const DB_NAME = 'openreader-db'; // Managed via Dexie (version bumped from the original manual IndexedDB) -const DB_VERSION = 4; +const 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; @@ -26,15 +28,159 @@ type OpenReaderDB = Dexie & { [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, + 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', - [CONFIG_TABLE]: 'key', + [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; @@ -90,10 +236,9 @@ export async function getAllPdfDocuments(): Promise { export async function removePdfDocument(id: string): Promise { await withDB(async () => { console.log('Removing PDF document via Dexie:', id); - const locationKey = `lastLocation_${id}`; - await db.transaction('readwrite', db[PDF_TABLE], db[CONFIG_TABLE], async () => { + await db.transaction('readwrite', db[PDF_TABLE], db[LAST_LOCATION_TABLE], async () => { await db[PDF_TABLE].delete(id); - await db[CONFIG_TABLE].delete(locationKey); + await db[LAST_LOCATION_TABLE].delete(id); }); }); } @@ -140,10 +285,9 @@ export async function getAllEpubDocuments(): Promise { export async function removeEpubDocument(id: string): Promise { await withDB(async () => { console.log('Removing EPUB document via Dexie:', id); - const locationKey = `lastLocation_${id}`; - await db.transaction('readwrite', db[EPUB_TABLE], db[CONFIG_TABLE], async () => { + await db.transaction('readwrite', db[EPUB_TABLE], db[LAST_LOCATION_TABLE], async () => { await db[EPUB_TABLE].delete(id); - await db[CONFIG_TABLE].delete(locationKey); + await db[LAST_LOCATION_TABLE].delete(id); }); }); } @@ -192,71 +336,66 @@ export async function clearHtmlDocuments(): Promise { }); } -// Config helpers +export async function getAppConfig(): Promise { + return withDB(async () => { + const row = await db[APP_CONFIG_TABLE].get('singleton'); + return row ?? null; + }); +} -export async function setConfigItem(key: string, value: string): Promise { +export async function updateAppConfig(partial: Partial): Promise { await withDB(async () => { - console.log('Setting config item via Dexie:', key); - await db[CONFIG_TABLE].put({ key, value }); + 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); + } }); } -export async function getConfigItem(key: string): Promise { - const row = await withDB(() => db[CONFIG_TABLE].get(key)); - console.log('Fetching config item via Dexie:', key, row ? 'found' : 'not found'); - return row ? row.value : null; -} - -export async function getAllConfigItems(): Promise> { - const rows = await withDB(() => db[CONFIG_TABLE].toArray()); - const config: Record = {}; - rows.forEach((row) => { - config[row.key] = row.value; - }); - console.log('Fetched config items via Dexie:', rows.length); - return config; -} - -export async function removeConfigItem(key: string): Promise { - await withDB(async () => { - console.log('Removing config item via Dexie:', key); - await db[CONFIG_TABLE].delete(key); - }); -} - -// Legacy-style config accessors retained for convenience - -export const getItem = getConfigItem; -export const setItem = setConfigItem; -export const removeItem = removeConfigItem; - // Document list state helpers export async function saveDocumentListState(state: DocumentListState): Promise { - await setConfigItem('documentListState', JSON.stringify(state)); + await updateAppConfig({ documentListState: state }); } export async function getDocumentListState(): Promise { - const stateStr = await getConfigItem('documentListState'); - if (!stateStr) return null; - try { - return JSON.parse(stateStr); - } catch (error) { - console.error('Error parsing document list state:', error); - return null; - } + 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 { - const key = `lastLocation_${docId}`; - return getConfigItem(key); + 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 { - const key = `lastLocation_${docId}`; - await setConfigItem(key, location); + 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) @@ -410,4 +549,3 @@ export async function loadDocumentsFromServer( return { lastSync: Date.now() }; } - diff --git a/tests/helpers.ts b/tests/helpers.ts index dd1a2df..6b1557c 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -342,7 +342,7 @@ export async function triggerViewportResize(page: Page, width: number, height: n await page.setViewportSize({ width, height }); } -// Wait for DocumentListState.showHint to persist in IndexedDB 'config' store +// Wait for DocumentListState.showHint to persist in IndexedDB 'app-config' store export async function waitForDocumentListHintPersist(page: Page, expected: boolean) { await page.waitForFunction(async (exp) => { try { @@ -353,21 +353,18 @@ export async function waitForDocumentListHintPersist(page: Page, expected: boole }); const db = await openDb(); const readConfig = () => new Promise((resolve, reject) => { - const tx = db.transaction(['config'], 'readonly'); - const store = tx.objectStore('config'); - const getReq = store.get('documentListState'); + const tx = db.transaction(['app-config'], 'readonly'); + const store = tx.objectStore('app-config'); + const getReq = store.get('singleton'); getReq.onsuccess = () => resolve(getReq.result); getReq.onerror = () => reject(getReq.error); }); const item = await readConfig(); db.close(); - if (!item || typeof item.value !== 'string') return false; - try { - const parsed = JSON.parse(item.value); - return parsed && parsed.showHint === exp; - } catch { - return false; - } + if (!item || typeof item.documentListState !== 'object') return false; + const state = item.documentListState; + if (!state || typeof state.showHint !== 'boolean') return false; + return state.showHint === exp; } catch { return false; }