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. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
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 to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* 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 { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
|
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 { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
|
|
@ -122,9 +122,9 @@ export function SettingsModal() {
|
||||||
// set firstVisit on initial load
|
// set firstVisit on initial load
|
||||||
const checkFirstVist = useCallback(async () => {
|
const checkFirstVist = useCallback(async () => {
|
||||||
if (!isDev) return;
|
if (!isDev) return;
|
||||||
const firstVisit = await getItem('firstVisit');
|
const firstVisit = await getFirstVisit();
|
||||||
if (firstVisit == null) {
|
if (!firstVisit) {
|
||||||
await setItem('firstVisit', 'true');
|
await setFirstVisit(true);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
}, [setIsOpen]);
|
}, [setIsOpen]);
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,13 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
|
||||||
import { getItem, setItem, removeItem, initDB } from '@/utils/dexie';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { db, initDB, updateAppConfig } from '@/utils/dexie';
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/appConfig';
|
||||||
|
export type { ViewType } from '@/types/appConfig';
|
||||||
/** 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>;
|
|
||||||
|
|
||||||
/** Configuration values for the application */
|
/** Configuration values for the application */
|
||||||
type ConfigValues = {
|
type ConfigValues = Omit<AppConfigRow, 'id'>;
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Interface defining the configuration context shape and functionality */
|
/** Interface defining the configuration context shape and functionality */
|
||||||
interface ConfigContextType {
|
interface ConfigContextType {
|
||||||
|
|
@ -68,26 +44,6 @@ const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
||||||
*/
|
*/
|
||||||
export function ConfigProvider({ children }: { children: ReactNode }) {
|
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 [isLoading, setIsLoading] = useState(true);
|
||||||
const [isDBReady, setIsDBReady] = useState(false);
|
const [isDBReady, setIsDBReady] = useState(false);
|
||||||
|
|
||||||
|
|
@ -100,164 +56,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await initDB();
|
await initDB();
|
||||||
setIsDBReady(true);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error initializing:', error);
|
console.error('Error initializing Dexie:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -266,6 +66,45 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
initializeDB();
|
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
|
* Updates multiple configuration values simultaneously
|
||||||
* Only saves API credentials if they are explicitly set
|
* 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 }>) => {
|
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
if (newConfig.apiKey !== undefined && newConfig.apiKey !== '') {
|
const updates: Partial<AppConfigRow> = {};
|
||||||
// Only save API key to IndexedDB if it's different from env default
|
if (newConfig.apiKey !== undefined) {
|
||||||
await setItem('apiKey', newConfig.apiKey!);
|
updates.apiKey = newConfig.apiKey;
|
||||||
setApiKey(newConfig.apiKey!);
|
|
||||||
}
|
}
|
||||||
if (newConfig.baseUrl !== undefined && newConfig.baseUrl !== '') {
|
if (newConfig.baseUrl !== undefined) {
|
||||||
// Only save base URL to IndexedDB if it's different from env default
|
updates.baseUrl = newConfig.baseUrl;
|
||||||
await setItem('baseUrl', newConfig.baseUrl!);
|
|
||||||
setBaseUrl(newConfig.baseUrl!);
|
|
||||||
}
|
}
|
||||||
|
await updateAppConfig(updates);
|
||||||
// Delete completely if '' is passed
|
|
||||||
if (newConfig.apiKey === '') {
|
|
||||||
await removeItem('apiKey');
|
|
||||||
setApiKey('');
|
|
||||||
}
|
|
||||||
if (newConfig.baseUrl === '') {
|
|
||||||
await removeItem('baseUrl');
|
|
||||||
setBaseUrl('');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating config:', error);
|
console.error('Error updating config:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -315,86 +141,35 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
if (key === 'voice') {
|
if (key === 'voice') {
|
||||||
const voiceKey = getVoiceKey(ttsProvider, ttsModel);
|
const voiceKey = getVoiceKey(ttsProvider, ttsModel);
|
||||||
const updatedSavedVoices = { ...savedVoices, [voiceKey]: value as string };
|
const updatedSavedVoices = { ...savedVoices, [voiceKey]: value as string };
|
||||||
setSavedVoices(updatedSavedVoices);
|
await updateAppConfig({
|
||||||
await setItem('savedVoices', JSON.stringify(updatedSavedVoices));
|
savedVoices: updatedSavedVoices,
|
||||||
setVoice(value as string);
|
voice: value as string,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Special handling for provider/model changes - restore saved voice if available
|
// Special handling for provider/model changes - restore saved voice if available
|
||||||
else if (key === 'ttsProvider' || key === 'ttsModel') {
|
else if (key === 'ttsProvider' || key === 'ttsModel') {
|
||||||
const newProvider = key === 'ttsProvider' ? (value as string) : ttsProvider;
|
const newProvider = key === 'ttsProvider' ? (value as string) : ttsProvider;
|
||||||
const newModel = key === 'ttsModel' ? (value as string) : ttsModel;
|
const newModel = key === 'ttsModel' ? (value as string) : ttsModel;
|
||||||
const voiceKey = getVoiceKey(newProvider, newModel);
|
const voiceKey = getVoiceKey(newProvider, newModel);
|
||||||
|
const restoredVoice = savedVoices[voiceKey] || '';
|
||||||
// Update provider or model
|
await updateAppConfig({
|
||||||
await setItem(key, value.toString());
|
[key]: value as ConfigValues[keyof ConfigValues],
|
||||||
if (key === 'ttsProvider') {
|
voice: restoredVoice,
|
||||||
setTTSProvider(value as string);
|
} as Partial<AppConfigRow>);
|
||||||
} 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('');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (key === 'savedVoices') {
|
else if (key === 'savedVoices') {
|
||||||
setSavedVoices(value as SavedVoices);
|
const newSavedVoices = value as SavedVoices;
|
||||||
await setItem('savedVoices', JSON.stringify(value));
|
await updateAppConfig({
|
||||||
|
savedVoices: newSavedVoices,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await setItem(key, value.toString());
|
await updateAppConfig({
|
||||||
switch (key) {
|
[key]: value as ConfigValues[keyof ConfigValues],
|
||||||
case 'apiKey':
|
} as Partial<AppConfigRow>);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error updating config key ${key}:`, error);
|
console.error(`Error updating config key ${String(key)}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
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 Dexie, { type EntityTable } from 'dexie';
|
||||||
import {
|
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/appConfig';
|
||||||
PDFDocument,
|
import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents';
|
||||||
EPUBDocument,
|
|
||||||
HTMLDocument,
|
|
||||||
DocumentListState,
|
|
||||||
SyncedDocument,
|
|
||||||
} from '@/types/documents';
|
|
||||||
|
|
||||||
const DB_NAME = 'openreader-db';
|
const DB_NAME = 'openreader-db';
|
||||||
// Managed via Dexie (version bumped from the original manual IndexedDB)
|
// 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 PDF_TABLE = 'pdf-documents' as const;
|
||||||
const EPUB_TABLE = 'epub-documents' as const;
|
const EPUB_TABLE = 'epub-documents' as const;
|
||||||
const HTML_TABLE = 'html-documents' as const;
|
const HTML_TABLE = 'html-documents' as const;
|
||||||
const CONFIG_TABLE = 'config' 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 {
|
export interface ConfigRow {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -26,15 +28,159 @@ type OpenReaderDB = Dexie & {
|
||||||
[EPUB_TABLE]: EntityTable<EPUBDocument, 'id'>;
|
[EPUB_TABLE]: EntityTable<EPUBDocument, 'id'>;
|
||||||
[HTML_TABLE]: EntityTable<HTMLDocument, 'id'>;
|
[HTML_TABLE]: EntityTable<HTMLDocument, 'id'>;
|
||||||
[CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>;
|
[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;
|
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({
|
db.version(DB_VERSION).stores({
|
||||||
[PDF_TABLE]: 'id, type, name, lastModified, size, folderId',
|
[PDF_TABLE]: 'id, type, name, lastModified, size, folderId',
|
||||||
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId',
|
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId',
|
||||||
[HTML_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;
|
let dbOpenPromise: Promise<void> | null = null;
|
||||||
|
|
@ -90,10 +236,9 @@ export async function getAllPdfDocuments(): Promise<PDFDocument[]> {
|
||||||
export async function removePdfDocument(id: string): Promise<void> {
|
export async function removePdfDocument(id: string): Promise<void> {
|
||||||
await withDB(async () => {
|
await withDB(async () => {
|
||||||
console.log('Removing PDF document via Dexie:', id);
|
console.log('Removing PDF document via Dexie:', id);
|
||||||
const locationKey = `lastLocation_${id}`;
|
await db.transaction('readwrite', db[PDF_TABLE], db[LAST_LOCATION_TABLE], async () => {
|
||||||
await db.transaction('readwrite', db[PDF_TABLE], db[CONFIG_TABLE], async () => {
|
|
||||||
await db[PDF_TABLE].delete(id);
|
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> {
|
export async function removeEpubDocument(id: string): Promise<void> {
|
||||||
await withDB(async () => {
|
await withDB(async () => {
|
||||||
console.log('Removing EPUB document via Dexie:', id);
|
console.log('Removing EPUB document via Dexie:', id);
|
||||||
const locationKey = `lastLocation_${id}`;
|
await db.transaction('readwrite', db[EPUB_TABLE], db[LAST_LOCATION_TABLE], async () => {
|
||||||
await db.transaction('readwrite', db[EPUB_TABLE], db[CONFIG_TABLE], async () => {
|
|
||||||
await db[EPUB_TABLE].delete(id);
|
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 () => {
|
await withDB(async () => {
|
||||||
console.log('Setting config item via Dexie:', key);
|
const table = db[APP_CONFIG_TABLE];
|
||||||
await db[CONFIG_TABLE].put({ key, value });
|
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
|
// Document list state helpers
|
||||||
|
|
||||||
export async function saveDocumentListState(state: DocumentListState): Promise<void> {
|
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> {
|
export async function getDocumentListState(): Promise<DocumentListState | null> {
|
||||||
const stateStr = await getConfigItem('documentListState');
|
const config = await getAppConfig();
|
||||||
if (!stateStr) return null;
|
if (!config || !config.documentListState) return null;
|
||||||
try {
|
return config.documentListState;
|
||||||
return JSON.parse(stateStr);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing document list state:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last-location helpers (used by TTS and readers)
|
// Last-location helpers (used by TTS and readers)
|
||||||
|
|
||||||
export async function getLastDocumentLocation(docId: string): Promise<string | null> {
|
export async function getLastDocumentLocation(docId: string): Promise<string | null> {
|
||||||
const key = `lastLocation_${docId}`;
|
return withDB(async () => {
|
||||||
return getConfigItem(key);
|
const row = await db[LAST_LOCATION_TABLE].get(docId);
|
||||||
|
return row ? row.location : null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setLastDocumentLocation(docId: string, location: string): Promise<void> {
|
export async function setLastDocumentLocation(docId: string, location: string): Promise<void> {
|
||||||
const key = `lastLocation_${docId}`;
|
await withDB(async () => {
|
||||||
await setConfigItem(key, location);
|
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)
|
// Sync helpers (server round-trip)
|
||||||
|
|
@ -410,4 +549,3 @@ export async function loadDocumentsFromServer(
|
||||||
|
|
||||||
return { lastSync: Date.now() };
|
return { lastSync: Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -342,7 +342,7 @@ export async function triggerViewportResize(page: Page, width: number, height: n
|
||||||
await page.setViewportSize({ width, height });
|
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) {
|
export async function waitForDocumentListHintPersist(page: Page, expected: boolean) {
|
||||||
await page.waitForFunction(async (exp) => {
|
await page.waitForFunction(async (exp) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -353,21 +353,18 @@ export async function waitForDocumentListHintPersist(page: Page, expected: boole
|
||||||
});
|
});
|
||||||
const db = await openDb();
|
const db = await openDb();
|
||||||
const readConfig = () => new Promise<any>((resolve, reject) => {
|
const readConfig = () => new Promise<any>((resolve, reject) => {
|
||||||
const tx = db.transaction(['config'], 'readonly');
|
const tx = db.transaction(['app-config'], 'readonly');
|
||||||
const store = tx.objectStore('config');
|
const store = tx.objectStore('app-config');
|
||||||
const getReq = store.get('documentListState');
|
const getReq = store.get('singleton');
|
||||||
getReq.onsuccess = () => resolve(getReq.result);
|
getReq.onsuccess = () => resolve(getReq.result);
|
||||||
getReq.onerror = () => reject(getReq.error);
|
getReq.onerror = () => reject(getReq.error);
|
||||||
});
|
});
|
||||||
const item = await readConfig();
|
const item = await readConfig();
|
||||||
db.close();
|
db.close();
|
||||||
if (!item || typeof item.value !== 'string') return false;
|
if (!item || typeof item.documentListState !== 'object') return false;
|
||||||
try {
|
const state = item.documentListState;
|
||||||
const parsed = JSON.parse(item.value);
|
if (!state || typeof state.showHint !== 'boolean') return false;
|
||||||
return parsed && parsed.showHint === exp;
|
return state.showHint === exp;
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue