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
573 lines
19 KiB
TypeScript
573 lines
19 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useEffect, useState } from 'react';
|
|
import { MdAdd, MdDelete, MdDragIndicator, MdInfoOutline } from 'react-icons/md';
|
|
import { IoMdCloseCircleOutline } from 'react-icons/io';
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
TouchSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from '@dnd-kit/core';
|
|
import {
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useFileSelector } from '@/hooks/useFileSelector';
|
|
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { evictProvider } from '@/services/dictionaries/registry';
|
|
import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
|
|
import type { ImportedDictionary, WebSearchEntry } from '@/services/dictionaries/types';
|
|
import {
|
|
getBuiltinWebSearch,
|
|
isValidUrlTemplate,
|
|
} from '@/services/dictionaries/webSearchTemplates';
|
|
|
|
interface CustomDictionariesProps {
|
|
onBack: () => void;
|
|
}
|
|
|
|
interface ProviderRow {
|
|
id: string;
|
|
label: string;
|
|
kind: 'builtin' | 'stardict' | 'mdict' | 'dict' | 'slob' | 'web';
|
|
badge: string;
|
|
imported?: ImportedDictionary;
|
|
/** Set on `kind: 'web'` rows. The shape distinguishes deletable custom
|
|
* entries (when `builtinWeb` is false) from immutable built-ins. */
|
|
webSearch?: WebSearchEntry;
|
|
builtinWeb?: boolean;
|
|
disabled?: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
const builtinWebLabel = (id: string, _: (key: string) => string): string => {
|
|
const tpl = getBuiltinWebSearch(id);
|
|
if (!tpl) return id;
|
|
return _(tpl.nameKey);
|
|
};
|
|
|
|
const builtinLabel = (id: string, _: (key: string) => string): string => {
|
|
if (id === BUILTIN_PROVIDER_IDS.wiktionary) return _('Wiktionary');
|
|
if (id === BUILTIN_PROVIDER_IDS.wikipedia) return _('Wikipedia');
|
|
return id;
|
|
};
|
|
|
|
interface SortableRowProps {
|
|
row: ProviderRow;
|
|
enabled: boolean;
|
|
isDeleteMode: boolean;
|
|
onToggle: (id: string, next: boolean) => void;
|
|
onDelete: (row: ProviderRow) => void;
|
|
onEditWebSearch?: (entry: WebSearchEntry) => void;
|
|
_: (key: string, options?: Record<string, number | string>) => string;
|
|
}
|
|
|
|
const SortableRow: React.FC<SortableRowProps> = ({
|
|
row,
|
|
enabled,
|
|
isDeleteMode,
|
|
onToggle,
|
|
onDelete,
|
|
onEditWebSearch,
|
|
_,
|
|
}) => {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
id: row.id,
|
|
});
|
|
|
|
const style: React.CSSProperties = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
// Keep the row visible while dragging; use a slight opacity dip so the
|
|
// user can tell it's the moving one.
|
|
opacity: isDragging ? 0.6 : 1,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={clsx(
|
|
'flex items-center gap-2 px-3 py-2 transition-colors',
|
|
isDragging ? 'bg-base-200 z-10 shadow-md' : 'hover:bg-base-200/40',
|
|
)}
|
|
>
|
|
{/* Drag handle (left). Always present so reorder works for built-ins
|
|
and imported alike. The handle is the only element that registers
|
|
drag listeners — clicks on the rest of the row don't initiate a
|
|
drag, which keeps the toggle and delete buttons clickable. */}
|
|
<button
|
|
type='button'
|
|
className='btn btn-ghost btn-xs h-7 w-5 cursor-grab touch-none p-0 active:cursor-grabbing'
|
|
aria-label={_('Drag to reorder')}
|
|
title={_('Drag to reorder')}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<MdDragIndicator className='text-base-content/60 h-4 w-4' />
|
|
</button>
|
|
|
|
<div className='min-w-0 flex-1'>
|
|
<div className='flex items-center gap-2'>
|
|
<span
|
|
className={clsx('truncate font-medium', row.disabled && 'text-base-content/60')}
|
|
title={row.label}
|
|
>
|
|
{row.label}
|
|
</span>
|
|
{row.kind === 'web' && !row.builtinWeb && row.webSearch && onEditWebSearch && (
|
|
<button
|
|
type='button'
|
|
onClick={() => onEditWebSearch(row.webSearch!)}
|
|
className='link link-hover text-base-content/60 shrink-0 text-xs'
|
|
>
|
|
{_('Edit')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{row.reason && (
|
|
<div className='text-warning mt-1 flex items-start gap-1 text-xs'>
|
|
<MdInfoOutline className='mt-0.5 h-3.5 w-3.5 shrink-0' />
|
|
<span>{row.reason}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* End-aligned type badge. Sits just before the toggle so all
|
|
badges form a uniform column regardless of name length, instead
|
|
of trailing the truncated name at a ragged x position. */}
|
|
<span className='badge badge-sm badge-ghost shrink-0'>{row.badge}</span>
|
|
|
|
<input
|
|
type='checkbox'
|
|
className='toggle toggle-sm shrink-0'
|
|
checked={enabled}
|
|
onChange={() => onToggle(row.id, !enabled)}
|
|
disabled={row.disabled}
|
|
aria-label={enabled ? _('Disable') : _('Enable')}
|
|
/>
|
|
|
|
{/* Delete X — for imported dictionaries and custom web searches, only
|
|
in delete mode. Built-ins (incl. built-in web searches) never show
|
|
it; deletable rows reserve no width when not in delete mode so the
|
|
toggles align across the list. */}
|
|
{(row.imported || (row.kind === 'web' && !row.builtinWeb)) && isDeleteMode && (
|
|
<button
|
|
type='button'
|
|
onClick={() => onDelete(row)}
|
|
className='btn btn-ghost btn-sm shrink-0 px-1'
|
|
aria-label={_('Delete')}
|
|
title={_('Delete')}
|
|
>
|
|
<IoMdCloseCircleOutline className='text-base-content/75 h-5 w-5' />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
|
|
const _ = useTranslation();
|
|
const { appService, envConfig } = useEnv();
|
|
const {
|
|
dictionaries,
|
|
settings,
|
|
addDictionary,
|
|
removeDictionary,
|
|
reorder,
|
|
setEnabled,
|
|
addWebSearch,
|
|
updateWebSearch,
|
|
removeWebSearch,
|
|
saveCustomDictionaries,
|
|
loadCustomDictionaries,
|
|
} = useCustomDictionaryStore();
|
|
|
|
useEffect(() => {
|
|
void loadCustomDictionaries(envConfig).catch(() => {});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const { selectFiles } = useFileSelector(appService, _);
|
|
const [importing, setImporting] = useState(false);
|
|
const [isDeleteMode, setIsDeleteMode] = useState(false);
|
|
|
|
// Add/edit web-search modal state. `editingId` is `null` for "add", a
|
|
// custom entry's id for "edit".
|
|
const [webModal, setWebModal] = useState<null | {
|
|
editingId: string | null;
|
|
name: string;
|
|
urlTemplate: string;
|
|
}>(null);
|
|
const openAddWebSearch = () => setWebModal({ editingId: null, name: '', urlTemplate: '' });
|
|
const openEditWebSearch = (entry: WebSearchEntry) =>
|
|
setWebModal({ editingId: entry.id, name: entry.name, urlTemplate: entry.urlTemplate });
|
|
const closeWebModal = () => setWebModal(null);
|
|
const submitWebModal = async () => {
|
|
if (!webModal) return;
|
|
const name = webModal.name.trim();
|
|
const url = webModal.urlTemplate.trim();
|
|
if (!name || !isValidUrlTemplate(url)) {
|
|
eventDispatcher.dispatch('toast', {
|
|
type: 'warning',
|
|
message: _('URL template must start with http(s):// and contain %WORD%.'),
|
|
timeout: 4000,
|
|
});
|
|
return;
|
|
}
|
|
if (webModal.editingId) {
|
|
updateWebSearch(webModal.editingId, { name, urlTemplate: url });
|
|
// Re-create the cached provider so the new template + label take effect.
|
|
evictProvider(webModal.editingId);
|
|
} else {
|
|
addWebSearch(name, url);
|
|
}
|
|
await saveCustomDictionaries(envConfig);
|
|
setWebModal(null);
|
|
};
|
|
|
|
const buildRows = (): ProviderRow[] => {
|
|
const dictById = new Map(dictionaries.map((d) => [d.id, d]));
|
|
const webById = new Map((settings.webSearches ?? []).map((w) => [w.id, w]));
|
|
const rows: ProviderRow[] = [];
|
|
for (const id of settings.providerOrder) {
|
|
if (id.startsWith('builtin:')) {
|
|
rows.push({
|
|
id,
|
|
label: builtinLabel(id, _),
|
|
kind: 'builtin',
|
|
badge: _('Built-in'),
|
|
});
|
|
continue;
|
|
}
|
|
if (id.startsWith('web:builtin:')) {
|
|
const tpl = getBuiltinWebSearch(id);
|
|
if (!tpl) continue;
|
|
rows.push({
|
|
id,
|
|
label: builtinWebLabel(id, _),
|
|
kind: 'web',
|
|
badge: _('Web'),
|
|
webSearch: tpl,
|
|
builtinWeb: true,
|
|
});
|
|
continue;
|
|
}
|
|
if (id.startsWith('web:')) {
|
|
const w = webById.get(id);
|
|
if (!w || w.deletedAt) continue;
|
|
rows.push({
|
|
id,
|
|
label: w.name,
|
|
kind: 'web',
|
|
badge: _('Web'),
|
|
webSearch: w,
|
|
builtinWeb: false,
|
|
});
|
|
continue;
|
|
}
|
|
const dict = dictById.get(id);
|
|
if (!dict || dict.deletedAt) continue;
|
|
let reason: string | undefined;
|
|
let disabled = false;
|
|
if (dict.unavailable) {
|
|
reason = _('Bundle is missing on this device. Re-import to use it.');
|
|
disabled = true;
|
|
} else if (dict.unsupported) {
|
|
reason = dict.unsupportedReason || _('This dictionary format is not supported.');
|
|
disabled = true;
|
|
}
|
|
rows.push({
|
|
id,
|
|
label: dict.name,
|
|
kind: dict.kind,
|
|
badge:
|
|
dict.kind === 'mdict'
|
|
? _('MDict')
|
|
: dict.kind === 'dict'
|
|
? _('DICT')
|
|
: dict.kind === 'slob'
|
|
? _('Slob')
|
|
: _('StarDict'),
|
|
imported: dict,
|
|
disabled,
|
|
reason,
|
|
});
|
|
}
|
|
return rows;
|
|
};
|
|
|
|
const rows = buildRows();
|
|
const hasDeletable = rows.some((r) => r.imported || (r.kind === 'web' && !r.builtinWeb));
|
|
|
|
// dnd-kit sensors. PointerSensor with a small distance gate avoids
|
|
// hijacking simple clicks on the drag handle. TouchSensor with a delay
|
|
// matches mobile UX (long-press to drag). Keyboard support gives drag
|
|
// accessibility for free.
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
);
|
|
|
|
const handleImport = async () => {
|
|
if (importing) return;
|
|
setImporting(true);
|
|
try {
|
|
const result = await selectFiles({ type: 'dictionaries', multiple: true });
|
|
if (result.error || result.files.length === 0) return;
|
|
const importResult = await appService?.importDictionaries(result.files);
|
|
if (!importResult) return;
|
|
let added = 0;
|
|
for (const dict of importResult.imported) {
|
|
addDictionary(dict);
|
|
added += 1;
|
|
}
|
|
await saveCustomDictionaries(envConfig);
|
|
if (added > 0) {
|
|
eventDispatcher.dispatch('toast', {
|
|
type: 'info',
|
|
message: _('Imported {{count}} dictionary', { count: added }),
|
|
timeout: 2500,
|
|
});
|
|
}
|
|
if (importResult.orphanFiles.length > 0) {
|
|
eventDispatcher.dispatch('toast', {
|
|
type: 'warning',
|
|
message: _('Skipped incomplete bundles: {{names}}', {
|
|
names: importResult.orphanFiles.join(', '),
|
|
}),
|
|
timeout: 4000,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
eventDispatcher.dispatch('toast', {
|
|
type: 'error',
|
|
message: _('Failed to import dictionary: {{message}}', {
|
|
message: err instanceof Error ? err.message : String(err),
|
|
}),
|
|
timeout: 4000,
|
|
});
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (row: ProviderRow) => {
|
|
if (row.imported) {
|
|
const dict = row.imported;
|
|
try {
|
|
await appService?.deleteDictionary(dict);
|
|
} catch (err) {
|
|
console.warn('Failed to delete dictionary files:', err);
|
|
}
|
|
removeDictionary(dict.id);
|
|
evictProvider(dict.id);
|
|
} else if (row.kind === 'web' && !row.builtinWeb && row.webSearch) {
|
|
removeWebSearch(row.webSearch.id);
|
|
evictProvider(row.id);
|
|
} else {
|
|
return;
|
|
}
|
|
await saveCustomDictionaries(envConfig);
|
|
// Auto-leave delete mode when the last deletable entry is gone — there's
|
|
// nothing left to delete.
|
|
const remaining = rows.filter(
|
|
(r) => r.id !== row.id && (r.imported || (r.kind === 'web' && !r.builtinWeb)),
|
|
);
|
|
if (remaining.length === 0) setIsDeleteMode(false);
|
|
};
|
|
|
|
const handleToggle = async (id: string, next: boolean) => {
|
|
setEnabled(id, next);
|
|
await saveCustomDictionaries(envConfig);
|
|
};
|
|
|
|
const handleDragEnd = async (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
const order = [...settings.providerOrder];
|
|
const fromIdx = order.indexOf(String(active.id));
|
|
const toIdx = order.indexOf(String(over.id));
|
|
if (fromIdx < 0 || toIdx < 0) return;
|
|
const [moved] = order.splice(fromIdx, 1);
|
|
if (!moved) return;
|
|
order.splice(toIdx, 0, moved);
|
|
reorder(order);
|
|
await saveCustomDictionaries(envConfig);
|
|
};
|
|
|
|
return (
|
|
<div className='w-full'>
|
|
<div className='mb-6 flex h-8 items-center justify-between'>
|
|
<div className='breadcrumbs py-1'>
|
|
<ul>
|
|
<li>
|
|
<button className='font-semibold' onClick={onBack}>
|
|
{_('Language')}
|
|
</button>
|
|
</li>
|
|
<li className='font-medium'>{_('Dictionaries')}</li>
|
|
</ul>
|
|
</div>
|
|
{hasDeletable && (
|
|
<button
|
|
onClick={() => setIsDeleteMode((v) => !v)}
|
|
className='btn btn-ghost btn-sm text-base-content gap-2'
|
|
title={isDeleteMode ? _('Cancel Delete') : _('Delete Dictionary')}
|
|
>
|
|
{isDeleteMode ? (
|
|
<>{_('Cancel')}</>
|
|
) : (
|
|
<>
|
|
<MdDelete className='h-4 w-4' />
|
|
{_('Delete')}
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Primary actions. Flat outline-primary buttons so they still read
|
|
as the page's primary CTAs but consume far less vertical space
|
|
than the previous bordered cards. On narrow screens (mobile) they
|
|
stack; on tablet+ they sit side-by-side. */}
|
|
<div className='mb-4 grid grid-cols-1 gap-2 sm:grid-cols-2'>
|
|
<button
|
|
type='button'
|
|
onClick={handleImport}
|
|
disabled={importing}
|
|
className='btn btn-outline btn-primary gap-2 normal-case'
|
|
>
|
|
<MdAdd className='h-5 w-5' />
|
|
<span className='line-clamp-1'>
|
|
{importing ? _('Importing…') : _('Import Dictionary')}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
onClick={openAddWebSearch}
|
|
className='btn btn-outline btn-primary gap-2 normal-case'
|
|
>
|
|
<MdAdd className='h-5 w-5' />
|
|
<span className='line-clamp-1'>{_('Add Web Search')}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className='card border-base-200 bg-base-100 overflow-hidden border'>
|
|
<div className='divide-base-200 divide-y'>
|
|
{rows.length === 0 && (
|
|
<div className='text-base-content/60 px-4 py-6 text-center text-sm'>
|
|
{_('No dictionaries available.')}
|
|
</div>
|
|
)}
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
|
|
{rows.map((row) => (
|
|
<SortableRow
|
|
key={row.id}
|
|
row={row}
|
|
enabled={settings.providerEnabled[row.id] !== false}
|
|
isDeleteMode={isDeleteMode}
|
|
onToggle={handleToggle}
|
|
onDelete={handleDelete}
|
|
onEditWebSearch={openEditWebSearch}
|
|
_={_}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='bg-base-200/40 mt-4 rounded-lg p-3'>
|
|
<div className='text-base-content/70 text-xs'>
|
|
<div className='mb-1.5 flex items-center gap-1.5 font-medium'>
|
|
<MdInfoOutline className='h-3.5 w-3.5' />
|
|
{_('Tips')}
|
|
</div>
|
|
<ul className='list-outside list-disc space-y-0.5 ps-4'>
|
|
<li>{_('StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).')}</li>
|
|
<li>{_('MDict bundles use .mdx files; companion .mdd files are optional.')}</li>
|
|
<li>{_('DICT bundles need a .index file and a .dict.dz file.')}</li>
|
|
<li>{_('Slob bundles need a .slob file.')}</li>
|
|
<li>{_('Select all the bundle files together when importing.')}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add / edit web-search modal. Lightweight inline `<dialog>` (daisyUI
|
|
modal classes); the heavier `Dialog.tsx` is overkill for a 2-field
|
|
form. */}
|
|
{webModal && (
|
|
<div className='modal modal-open' role='dialog'>
|
|
<div className='modal-box w-11/12 max-w-md'>
|
|
<h3 className='text-base font-semibold'>
|
|
{webModal.editingId ? _('Edit Web Search') : _('Add Web Search')}
|
|
</h3>
|
|
<div className='mt-4 space-y-3'>
|
|
<label className='form-control w-full'>
|
|
<span className='label-text text-sm'>{_('Name')}</span>
|
|
<input
|
|
type='text'
|
|
className='input input-bordered input-sm w-full'
|
|
value={webModal.name}
|
|
placeholder={_('e.g. Google')}
|
|
onChange={(e) => setWebModal((m) => (m ? { ...m, name: e.target.value } : m))}
|
|
/>
|
|
</label>
|
|
<label className='form-control w-full'>
|
|
<span className='label-text text-sm'>{_('URL Template')}</span>
|
|
<input
|
|
type='url'
|
|
className='input input-bordered input-sm w-full'
|
|
value={webModal.urlTemplate}
|
|
placeholder='https://www.google.com/search?q=%WORD%'
|
|
onChange={(e) =>
|
|
setWebModal((m) => (m ? { ...m, urlTemplate: e.target.value } : m))
|
|
}
|
|
/>
|
|
<span className='label-text-alt text-base-content/60 mt-1 text-xs'>
|
|
{_('Use %WORD% where the looked-up word should appear.')}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
<div className='modal-action'>
|
|
<button type='button' onClick={closeWebModal} className='btn btn-ghost btn-sm'>
|
|
{_('Cancel')}
|
|
</button>
|
|
<button type='button' onClick={submitWebModal} className='btn btn-primary btn-sm'>
|
|
{_('Save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/* Click-outside dismiss. */}
|
|
<button
|
|
type='button'
|
|
aria-label={_('Close')}
|
|
className='modal-backdrop'
|
|
onClick={closeWebModal}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CustomDictionaries;
|