Files
readest/apps/readest-app/src/hooks/useFileSelector.ts
T
Huang Xin 7bb1133706 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
2026-05-03 20:07:27 +02:00

155 lines
4.4 KiB
TypeScript

import { AppService } from '@/types/system';
import { isTauriAppPlatform } from '@/services/environment';
import { basename } from '@tauri-apps/api/path';
import { stubTranslation as _ } from '@/utils/misc';
import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants';
export interface FileSelectorOptions {
type: SelectionType;
accept?: string;
multiple?: boolean;
extensions?: string[];
dialogTitle?: string;
}
export interface SelectedFile {
// For Web file
file?: File;
// For Tauri file
path?: string;
basePath?: string;
}
export interface FileSelectionResult {
files: SelectedFile[];
error?: string;
}
const selectFileWeb = (options: FileSelectorOptions): Promise<File[]> => {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = options.accept || '*/*';
fileInput.multiple = options.multiple || false;
fileInput.click();
fileInput.onchange = () => {
resolve(Array.from(fileInput.files || []));
};
});
};
const selectFileTauri = async (
options: FileSelectorOptions,
appService: AppService,
_: (key: string) => string,
): Promise<string[]> => {
const noFilter = appService?.isIOSApp || (appService?.isAndroidApp && options.type === 'books');
const exts = noFilter ? [] : options.extensions || [];
const title = options.dialogTitle || _('Select Files');
let files = (await appService?.selectFiles(_(title), exts)) || [];
if (noFilter && options.extensions) {
files = await Promise.all(
files.map(async (file: string) => {
let processedFile = file;
if (appService?.isAndroidApp && file.startsWith('content://')) {
processedFile = await basename(file);
}
const fileExt = processedFile.split('.').pop()?.toLowerCase() || 'unknown';
const extensions = options.extensions!;
const shouldInclude = extensions.includes(fileExt) || extensions.includes('*');
return shouldInclude ? file : null;
}),
).then((results) => results.filter((file) => file !== null));
}
return files;
};
const processWebFiles = (files: File[]): SelectedFile[] => {
return files.map((file) => ({
file,
}));
};
const processTauriFiles = (files: string[]): SelectedFile[] => {
return files.map((path) => ({
path,
}));
};
export const useFileSelector = (appService: AppService | null, _: (key: string) => string) => {
const selectFiles = async (options: FileSelectorOptions = { type: 'generic' }) => {
options = { ...FILE_SELECTION_PRESETS[options.type], ...options };
if (!appService) {
return { files: [] as SelectedFile[], error: 'App service is not available' };
}
try {
if (isTauriAppPlatform()) {
const filePaths = await selectFileTauri(options, appService, _);
const files = await processTauriFiles(filePaths);
return { files };
} else {
const webFiles = await selectFileWeb(options);
const files = processWebFiles(webFiles);
return { files };
}
} catch (error) {
return {
files: [],
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
};
return {
selectFiles,
};
};
export const FILE_SELECTION_PRESETS = {
generic: {
accept: '*/*',
extensions: ['*'],
dialogTitle: _('Select Files'),
},
images: {
accept: 'image/*',
extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
dialogTitle: _('Select Image'),
},
videos: {
accept: 'video/*',
extensions: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'],
dialogTitle: _('Select Video'),
},
audio: {
accept: 'audio/*',
extensions: ['mp3', 'wav', 'ogg', 'flac', 'm4a'],
dialogTitle: _('Select Audio'),
},
books: {
accept: BOOK_ACCEPT_FORMATS,
extensions: SUPPORTED_BOOK_EXTS,
dialogTitle: _('Select Books'),
},
fonts: {
accept: '.ttf, .otf, .woff, .woff2',
extensions: ['ttf', 'otf', 'woff', 'woff2'],
dialogTitle: _('Select Fonts'),
},
dictionaries: {
accept: '.mdx, .mdd, .ifo, .idx, .dict, .dz, .syn, .index, .slob',
extensions: ['mdx', 'mdd', 'ifo', 'idx', 'dict', 'dz', 'syn', 'index', 'slob'],
dialogTitle: _('Select Dictionary Files'),
},
covers: {
accept: '.png, .jpg, .jpeg, .gif',
extensions: ['png', 'jpg', 'jpeg', 'gif'],
dialogTitle: _('Select Image'),
},
};
export type SelectionType = keyof typeof FILE_SELECTION_PRESETS;