forked from akai/readest
7bb1133706
Extends the dictionary system beyond StarDict/MDict with two more open
formats and a pluggable Web Search tier so users can fall back to online
sources when their offline bundles miss a word.
Formats:
- DICT (dictd, RFC 2229): .index + .dict.dz bundles. Shared DictZip
parsing with StarDict via new dictZip.ts helper.
- Slob (Aard 2): self-contained .slob containers, zlib-compressed
utf-8 entries; non-zlib/non-utf-8 bundles flagged unsupported at
import.
Web Search:
- Built-in templates for Google, Urban Dictionary, Merriam-Webster
(seeded into providerOrder, disabled by default).
- Custom URL templates via %WORD% placeholder, URL-encoded at
substitution; entries persist in settings.webSearches.
- V1 renders an "Open in {{name}}" external link (iframe embedding is
blocked by every major target site's X-Frame-Options).
UI:
- CustomDictionaries panel: flat outline-primary buttons for Import /
Add Web Search, end-aligned type badges for a uniform column,
hover states, compact tips block.
- Dictionary popup: bottom-right Manage icon (tooltip-only) deep-links
into Settings → Language → Dictionaries; rounded-corner clipping fix
on the tab strip.
File picker accepts .index and .slob; importer recognizes DICT and
Slob bundles and reads bundle metadata for friendly names.
Tests cover DICT/Slob readers and providers with real freedict-eng-nld
fixtures, web search substitution + provider rendering, and the new
store CRUD for web searches.
Closes #4038
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
import { create } from 'zustand';
|
|
import { EnvConfigType } from '@/services/environment';
|
|
import type {
|
|
DictionarySettings,
|
|
ImportedDictionary,
|
|
WebSearchEntry,
|
|
} from '@/services/dictionaries/types';
|
|
import { BUILTIN_PROVIDER_IDS, BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types';
|
|
import { useSettingsStore } from './settingsStore';
|
|
|
|
/**
|
|
* Built-in web-search ids are seeded into `providerOrder` but disabled by
|
|
* default — users opt in. This preserves the principle that we don't push
|
|
* users onto external pages without consent, while still surfacing the
|
|
* options in the settings list.
|
|
*/
|
|
const BUILTIN_WEB_ORDER = [
|
|
BUILTIN_WEB_SEARCH_IDS.google,
|
|
BUILTIN_WEB_SEARCH_IDS.urban,
|
|
BUILTIN_WEB_SEARCH_IDS.merriamWebster,
|
|
];
|
|
|
|
const DEFAULT_DICTIONARY_SETTINGS: DictionarySettings = {
|
|
providerOrder: [
|
|
BUILTIN_PROVIDER_IDS.wiktionary,
|
|
BUILTIN_PROVIDER_IDS.wikipedia,
|
|
...BUILTIN_WEB_ORDER,
|
|
],
|
|
providerEnabled: {
|
|
[BUILTIN_PROVIDER_IDS.wiktionary]: true,
|
|
[BUILTIN_PROVIDER_IDS.wikipedia]: true,
|
|
[BUILTIN_WEB_SEARCH_IDS.google]: false,
|
|
[BUILTIN_WEB_SEARCH_IDS.urban]: false,
|
|
[BUILTIN_WEB_SEARCH_IDS.merriamWebster]: false,
|
|
},
|
|
webSearches: [],
|
|
};
|
|
|
|
interface DictionaryStoreState {
|
|
/** Imported (non-builtin) dictionaries. Soft-deleted entries are kept until next save. */
|
|
dictionaries: ImportedDictionary[];
|
|
settings: DictionarySettings;
|
|
|
|
/** Imported entries currently visible (not soft-deleted, sorted by addedAt desc). */
|
|
getAvailableDictionaries(): ImportedDictionary[];
|
|
getDictionary(id: string): ImportedDictionary | undefined;
|
|
|
|
/** Add (or revive) an imported dictionary. New entries are appended to providerOrder + enabled. */
|
|
addDictionary(dict: ImportedDictionary): void;
|
|
/** Soft-delete an imported entry by id; remove from providerOrder + providerEnabled. */
|
|
removeDictionary(id: string): boolean;
|
|
/** Replace a subset of provider ids in providerOrder; ignores unknown ids. */
|
|
reorder(ids: string[]): void;
|
|
/** Toggle a provider's enabled flag. Both builtin and imported ids are accepted. */
|
|
setEnabled(id: string, enabled: boolean): void;
|
|
/** Persist the last-used tab id so the popup re-opens on it. */
|
|
setDefaultProviderId(id: string | undefined): void;
|
|
|
|
/** Add a custom web search (id is generated). Appended + enabled by default. */
|
|
addWebSearch(name: string, urlTemplate: string): WebSearchEntry;
|
|
/** Update an existing custom web search; no-op if id is unknown or built-in. */
|
|
updateWebSearch(id: string, patch: { name?: string; urlTemplate?: string }): void;
|
|
/** Soft-delete a custom web search and remove from order/enabled. */
|
|
removeWebSearch(id: string): boolean;
|
|
|
|
/** Hydrate from `settings.customDictionaries` + `settings.dictionarySettings` + check on-disk availability. */
|
|
loadCustomDictionaries(envConfig: EnvConfigType): Promise<void>;
|
|
/** Persist current state back into settings (which then syncs to cloud). */
|
|
saveCustomDictionaries(envConfig: EnvConfigType): Promise<void>;
|
|
}
|
|
|
|
function toSettingsDict(dict: ImportedDictionary): ImportedDictionary {
|
|
// Strip transient fields before persisting. `unavailable` is recomputed at
|
|
// load time from the actual filesystem state, so don't write it.
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { unavailable: _u, ...rest } = dict;
|
|
return rest;
|
|
}
|
|
|
|
export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get) => ({
|
|
dictionaries: [],
|
|
settings: { ...DEFAULT_DICTIONARY_SETTINGS },
|
|
|
|
getAvailableDictionaries: () =>
|
|
get()
|
|
.dictionaries.filter((d) => !d.deletedAt)
|
|
.sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0)),
|
|
|
|
getDictionary: (id) => get().dictionaries.find((d) => d.id === id),
|
|
|
|
addDictionary: (dict) => {
|
|
set((state) => {
|
|
const existingIdx = state.dictionaries.findIndex((d) => d.id === dict.id);
|
|
const dictionaries =
|
|
existingIdx >= 0
|
|
? state.dictionaries.map((d, i) =>
|
|
i === existingIdx ? { ...dict, deletedAt: undefined } : d,
|
|
)
|
|
: [...state.dictionaries, dict];
|
|
const order = state.settings.providerOrder.includes(dict.id)
|
|
? state.settings.providerOrder
|
|
: [...state.settings.providerOrder, dict.id];
|
|
const enabled = { ...state.settings.providerEnabled };
|
|
if (!(dict.id in enabled)) enabled[dict.id] = !dict.unsupported;
|
|
return {
|
|
dictionaries,
|
|
settings: { ...state.settings, providerOrder: order, providerEnabled: enabled },
|
|
};
|
|
});
|
|
},
|
|
|
|
removeDictionary: (id) => {
|
|
const dict = get().dictionaries.find((d) => d.id === id);
|
|
if (!dict) return false;
|
|
set((state) => ({
|
|
dictionaries: state.dictionaries.map((d) =>
|
|
d.id === id ? { ...d, deletedAt: Date.now() } : d,
|
|
),
|
|
settings: {
|
|
...state.settings,
|
|
providerOrder: state.settings.providerOrder.filter((p) => p !== id),
|
|
providerEnabled: Object.fromEntries(
|
|
Object.entries(state.settings.providerEnabled).filter(([k]) => k !== id),
|
|
),
|
|
},
|
|
}));
|
|
return true;
|
|
},
|
|
|
|
reorder: (ids) => {
|
|
set((state) => {
|
|
// Keep only ids that still exist; tail any known ids missing from the input.
|
|
const known = new Set(state.settings.providerOrder);
|
|
const filtered = ids.filter((id) => known.has(id));
|
|
const tail = state.settings.providerOrder.filter((id) => !filtered.includes(id));
|
|
return {
|
|
settings: { ...state.settings, providerOrder: [...filtered, ...tail] },
|
|
};
|
|
});
|
|
},
|
|
|
|
setEnabled: (id, enabled) => {
|
|
set((state) => ({
|
|
settings: {
|
|
...state.settings,
|
|
providerEnabled: { ...state.settings.providerEnabled, [id]: enabled },
|
|
},
|
|
}));
|
|
},
|
|
|
|
setDefaultProviderId: (id) => {
|
|
set((state) => ({
|
|
settings: { ...state.settings, defaultProviderId: id },
|
|
}));
|
|
},
|
|
|
|
addWebSearch: (name, urlTemplate) => {
|
|
const trimmedName = name.trim();
|
|
const trimmedUrl = urlTemplate.trim();
|
|
const id = `web:${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
|
|
const entry: WebSearchEntry = { id, name: trimmedName, urlTemplate: trimmedUrl };
|
|
set((state) => {
|
|
const list = state.settings.webSearches ?? [];
|
|
const order = state.settings.providerOrder.includes(id)
|
|
? state.settings.providerOrder
|
|
: [...state.settings.providerOrder, id];
|
|
const enabled = { ...state.settings.providerEnabled, [id]: true };
|
|
return {
|
|
settings: {
|
|
...state.settings,
|
|
webSearches: [...list, entry],
|
|
providerOrder: order,
|
|
providerEnabled: enabled,
|
|
},
|
|
};
|
|
});
|
|
return entry;
|
|
},
|
|
|
|
updateWebSearch: (id, patch) => {
|
|
if (id.startsWith('web:builtin:')) return;
|
|
set((state) => {
|
|
const list = state.settings.webSearches ?? [];
|
|
if (!list.some((t) => t.id === id)) return state;
|
|
const next = list.map((t) =>
|
|
t.id === id
|
|
? {
|
|
...t,
|
|
name: patch.name?.trim() ?? t.name,
|
|
urlTemplate: patch.urlTemplate?.trim() ?? t.urlTemplate,
|
|
}
|
|
: t,
|
|
);
|
|
return { settings: { ...state.settings, webSearches: next } };
|
|
});
|
|
},
|
|
|
|
removeWebSearch: (id) => {
|
|
if (id.startsWith('web:builtin:')) return false;
|
|
const list = get().settings.webSearches ?? [];
|
|
if (!list.some((t) => t.id === id)) return false;
|
|
set((state) => ({
|
|
settings: {
|
|
...state.settings,
|
|
webSearches: (state.settings.webSearches ?? []).map((t) =>
|
|
t.id === id ? { ...t, deletedAt: Date.now() } : t,
|
|
),
|
|
providerOrder: state.settings.providerOrder.filter((p) => p !== id),
|
|
providerEnabled: Object.fromEntries(
|
|
Object.entries(state.settings.providerEnabled).filter(([k]) => k !== id),
|
|
),
|
|
},
|
|
}));
|
|
return true;
|
|
},
|
|
|
|
loadCustomDictionaries: async (envConfig) => {
|
|
try {
|
|
const { settings } = useSettingsStore.getState();
|
|
const persisted = settings?.customDictionaries ?? [];
|
|
const persistedSettings = settings?.dictionarySettings ?? DEFAULT_DICTIONARY_SETTINGS;
|
|
const appService = await envConfig.getAppService();
|
|
const dictionaries = await Promise.all(
|
|
persisted.map(async (dict) => {
|
|
if (dict.deletedAt) return dict;
|
|
const exists = await appService.exists(dict.bundleDir, 'Dictionaries');
|
|
return exists ? dict : { ...dict, unavailable: true };
|
|
}),
|
|
);
|
|
// Merge defaults to back-fill any missing keys (e.g. new builtin added in a release).
|
|
// For providerOrder, we append any newly-defaulted ids (like the
|
|
// built-in web searches added in this release) so existing users see
|
|
// them appear at the end of the list.
|
|
const persistedOrder = persistedSettings.providerOrder;
|
|
const orderSet = new Set(persistedOrder);
|
|
const merged: string[] = persistedOrder.length
|
|
? [...persistedOrder]
|
|
: [...DEFAULT_DICTIONARY_SETTINGS.providerOrder];
|
|
for (const id of DEFAULT_DICTIONARY_SETTINGS.providerOrder) {
|
|
if (!orderSet.has(id)) merged.push(id);
|
|
}
|
|
const settingsMerged: DictionarySettings = {
|
|
providerOrder: merged,
|
|
providerEnabled: {
|
|
...DEFAULT_DICTIONARY_SETTINGS.providerEnabled,
|
|
...persistedSettings.providerEnabled,
|
|
},
|
|
defaultProviderId: persistedSettings.defaultProviderId,
|
|
webSearches: persistedSettings.webSearches ?? [],
|
|
};
|
|
set({ dictionaries, settings: settingsMerged });
|
|
} catch (error) {
|
|
console.error('Failed to load custom dictionaries settings:', error);
|
|
}
|
|
},
|
|
|
|
saveCustomDictionaries: async (envConfig) => {
|
|
try {
|
|
const { settings, setSettings, saveSettings } = useSettingsStore.getState();
|
|
const { dictionaries, settings: dictSettings } = get();
|
|
settings.customDictionaries = dictionaries.map(toSettingsDict);
|
|
settings.dictionarySettings = dictSettings;
|
|
setSettings(settings);
|
|
saveSettings(envConfig, settings);
|
|
} catch (error) {
|
|
console.error('Failed to save custom dictionaries settings:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
}));
|