feat(dictionaries): add DICT/Slob formats and Web Search providers (#4048)

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
This commit is contained in:
Huang Xin
2026-05-04 02:07:27 +08:00
committed by GitHub
parent 8e7b2192d5
commit 7bb1133706
58 changed files with 11059 additions and 391 deletions
@@ -51,7 +51,8 @@ import ExportMarkdownDialog from './ExportMarkdownDialog';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { settings, setSettingsDialogBookKey, setSettingsDialogOpen, setActiveSettingsItemId } =
useSettingsStore();
const { isDarkMode } = useThemeStore();
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
@@ -970,6 +971,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
popupWidth={dictPopupWidth}
popupHeight={dictPopupHeight}
onDismiss={handleDismissPopupAndSelection}
onManage={() => {
// Dismiss the popup so the user returns to the reader cleanly
// when they close settings; the dictionaries sub-page in the
// SettingsDialog is enough surface for managing providers.
handleDismissPopupAndSelection();
setSettingsDialogBookKey(bookKey);
setActiveSettingsItemId('settings.language.dictionaries.manage');
setSettingsDialogOpen(true);
}}
/>
)}
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MdArrowBack } from 'react-icons/md';
import { MdArrowBack, MdSettings } from 'react-icons/md';
import clsx from 'clsx';
import { openUrl } from '@tauri-apps/plugin-opener';
@@ -22,6 +22,13 @@ interface DictionaryPopupProps {
popupWidth: number;
popupHeight: number;
onDismiss?: () => void;
/**
* Invoked when the user clicks the bottom-right "Manage Dictionaries"
* icon. The host (Annotator) decides how to navigate — typically by
* opening the SettingsDialog and deep-linking to the dictionaries
* sub-page.
*/
onManage?: () => void;
}
interface TabState {
@@ -45,6 +52,7 @@ const DictionaryPopup: React.FC<DictionaryPopupProps> = ({
popupWidth,
popupHeight,
onDismiss,
onManage,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
@@ -294,7 +302,11 @@ const DictionaryPopup: React.FC<DictionaryPopupProps> = ({
className='select-text'
onDismiss={onDismiss}
>
<div className='relative flex h-full flex-col'>
{/* `overflow-hidden rounded-lg` clips child surfaces (the tab strip's
gray bg, the bottom footer divider, etc.) to the popup's rounded
shape — without it the tab bar's `bg-base-300/40` and `border-b`
paint into the corner area and break the rounded edge. */}
<div className='relative flex h-full flex-col overflow-hidden rounded-lg'>
{providers.length > 1 && (
<div
role='tablist'
@@ -386,14 +398,33 @@ const DictionaryPopup: React.FC<DictionaryPopupProps> = ({
})
)}
{sourceLabel && (
<footer
className='not-eink:opacity-60 mt-auto flex items-center px-4 py-2 text-sm'
title={`Source: ${sourceLabel}`}
>
{/* min-w-0 lets the truncate engage inside the flex parent —
without it the span sizes to its content and overflows. */}
<span className='min-w-0 flex-1 truncate'>Source: {sourceLabel}</span>
{/* Footer always renders so the manage-dictionaries icon has a
consistent home at the bottom-right of every tab. The source
label fills the left side when present; otherwise the spacer
pushes the icon to the right. */}
{(sourceLabel || onManage) && (
<footer className='mt-auto flex shrink-0 items-center gap-2 px-3 py-1.5'>
{sourceLabel ? (
<span
className='not-eink:opacity-60 min-w-0 flex-1 truncate text-sm'
title={`Source: ${sourceLabel}`}
>
Source: {sourceLabel}
</span>
) : (
<span className='flex-1' />
)}
{onManage && (
<button
type='button'
onClick={onManage}
aria-label={_('Manage Dictionaries')}
title={_('Manage Dictionaries')}
className='btn btn-ghost btn-square btn-xs text-base-content/60 hover:text-base-content not-eink:hover:bg-base-200/60 shrink-0'
>
<MdSettings size={16} />
</button>
)}
</footer>
)}
</div>