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:
parent
d1dd3bd351
commit
7271afa2f5
6 changed files with 343 additions and 372 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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
61
src/types/appConfig.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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() };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue