refactor(config): centralize app config in Dexie

Migrates scattered configuration items and last document locations
from the legacy `config` table to new, dedicated Dexie stores.

- Introduces a singleton `app-config` table (`AppConfigRow`) to standardize
  and structure all application settings in a single object.
- Creates a `last-locations` table for efficient storage of document read positions.
- Updates `ConfigContext` to utilize `dexie-react-hooks`' `useLiveQuery`
  for reactive and simplified state management.
- Implements a database upgrade path (from DB_VERSION 4 to 5) to migrate
  all existing user settings seamlessly.
- Simplifies config access and updates with new `getAppConfig` and `updateAppConfig`
  utility functions.
- Removes the deprecated `config` table after successful migration.
- Updates consumers like `SettingsModal` and test helpers to align with
  the new config structure.
This commit is contained in:
Richard Roberson 2025-11-15 17:29:56 -07:00
parent d1dd3bd351
commit 7271afa2f5
6 changed files with 343 additions and 372 deletions

View file

@ -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. */

View file

@ -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]);

View file

@ -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<string, string>;
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<AppConfigRow, 'id'>;
/** Interface defining the configuration context shape and functionality */
interface ConfigContextType {
@ -68,26 +44,6 @@ const ConfigContext = createContext<ConfigContextType | undefined>(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<string>('');
const [baseUrl, setBaseUrl] = useState<string>('');
const [viewType, setViewType] = useState<ViewType>('single');
const [voiceSpeed, setVoiceSpeed] = useState<number>(1);
const [audioPlayerSpeed, setAudioPlayerSpeed] = useState<number>(1);
const [voice, setVoice] = useState<string>('af_sarah');
const [skipBlank, setSkipBlank] = useState<boolean>(true);
const [epubTheme, setEpubTheme] = useState<boolean>(false);
const [smartSentenceSplitting, setSmartSentenceSplitting] = useState<boolean>(true);
const [headerMargin, setHeaderMargin] = useState<number>(0.0);
const [footerMargin, setFooterMargin] = useState<number>(0.0);
const [leftMargin, setLeftMargin] = useState<number>(0.0);
const [rightMargin, setRightMargin] = useState<number>(0.0);
const [ttsProvider, setTTSProvider] = useState<string>('custom-openai');
const [ttsModel, setTTSModel] = useState<string>('kokoro');
const [ttsInstructions, setTTSInstructions] = useState<string>('');
const [savedVoices, setSavedVoices] = useState<SavedVoices>({});
const [pdfHighlightEnabled, setPdfHighlightEnabled] = useState<boolean>(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<AppConfigRow> = {};
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<AppConfigRow>);
}
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<AppConfigRow>);
}
} catch (error) {
console.error(`Error updating config key ${key}:`, error);
console.error(`Error updating config key ${String(key)}:`, error);
throw error;
} finally {
setIsLoading(false);

61
src/types/appConfig.ts Normal file
View file

@ -0,0 +1,61 @@
import type { DocumentListState } from '@/types/documents';
export type ViewType = 'single' | 'dual' | 'scroll';
export type SavedVoices = Record<string, string>;
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;
}

View file

@ -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<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,
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<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;
@ -90,10 +236,9 @@ 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 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<EPUBDocument[]> {
export async function removeEpubDocument(id: string): Promise<void> {
await withDB(async () => {
console.log('Removing EPUB document via Dexie:', id);
const locationKey = `lastLocation_${id}`;
await db.transaction('readwrite', db[EPUB_TABLE], db[CONFIG_TABLE], async () => {
await db.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<void> {
});
}
// Config helpers
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 setConfigItem(key: string, value: string): Promise<void> {
export async function updateAppConfig(partial: Partial<AppConfigRow>): Promise<void> {
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<string | null> {
const row = await withDB(() => db[CONFIG_TABLE].get(key));
console.log('Fetching config item via Dexie:', key, row ? 'found' : 'not found');
return row ? row.value : null;
}
export async function getAllConfigItems(): Promise<Record<string, string>> {
const rows = await withDB(() => db[CONFIG_TABLE].toArray());
const config: Record<string, string> = {};
rows.forEach((row) => {
config[row.key] = row.value;
});
console.log('Fetched config items via Dexie:', rows.length);
return config;
}
export async function removeConfigItem(key: string): Promise<void> {
await withDB(async () => {
console.log('Removing config item via Dexie:', key);
await db[CONFIG_TABLE].delete(key);
});
}
// Legacy-style config accessors retained for convenience
export const getItem = getConfigItem;
export const setItem = setConfigItem;
export const removeItem = removeConfigItem;
// Document list state helpers
export async function saveDocumentListState(state: DocumentListState): Promise<void> {
await setConfigItem('documentListState', JSON.stringify(state));
await updateAppConfig({ documentListState: state });
}
export async function getDocumentListState(): Promise<DocumentListState | null> {
const stateStr = await getConfigItem('documentListState');
if (!stateStr) return null;
try {
return JSON.parse(stateStr);
} catch (error) {
console.error('Error parsing document list state:', error);
return null;
}
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> {
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<void> {
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<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)
@ -410,4 +549,3 @@ export async function loadDocumentsFromServer(
return { lastSync: Date.now() };
}

View file

@ -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<any>((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;
}