5a0a70a30a
* feat(reader): custom dictionaries (StarDict + MDict) Adds a pluggable dictionary provider system. Built-in Wiktionary + Wikipedia (extracted from the legacy single-popup model into a tabbed shell) plus user-importable StarDict (.ifo/.idx/.dict.dz/.syn) and MDict (.mdx/.mdd) bundles. Settings → Language → Dictionaries: import / enable / drag-reorder / delete (delete-mode toggle mirrors CustomFonts). Drag uses @dnd-kit with pointer/touch/keyboard sensors. Reader popup: tabbed UI, per-tab lookup history, scroll-aware back button, last-active tab persists. Tabs grow to natural width up to a cap, truncate with ellipsis when crowded; phantom bold layer prevents layout shift on focus. StarDict reader is self-contained (replaces unused foliate-js/dict.js), with lazy random-access binary search on .idx + .syn (~420 KB Int32Array of byte offsets vs ~10 MB of parsed JS objects), lazy DictZip chunk decompression via fflate streaming Inflate (cmudict/eng-nld both chunked), and an optional .idx.offsets sidecar generated at import to skip the init scan. Cmudict 105K-entry init drops from ~10 MB heap and 2 MB IO to ~1.7 MB heap and ~500 KB IO. MDict uses the readest/js-mdict fork (added as a submodule, consumed via tsconfig paths so deps stay out of readest's pnpm-lock) which adds a browser-friendly BlobScanner reading via blob.slice(...).arrayBuffer() — slices are lazy when the Blob is Readest's NativeFile / RemoteFile. encrypt=2 (key-info-only) MDX is fully supported via ripemd128-based mdxDecrypt; encrypt=1 (record-block, needs user passcode) surfaces as unsupported. Wikipedia annotation tool removed (Wikipedia is now a tab inside the unified popup); legacy WiktionaryPopup / WikipediaPopup deleted. Stale annotationQuickAction === 'wikipedia' coerced to 'dictionary' on settings load. iOS-friendly external links: skip target="_blank" on Tauri to avoid the WebView's "open externally" path triggering the shell scope error; the popup's container click handler routes through openUrl. i18n: 939 strings translated across 31 locales (30 base keys + CLDR plural forms for ar/he/sl/pl/ru/uk/ro/it/pt/fr/es). Test fixtures bundled: cmudict (StarDict, 105K entries), eng-nld (StarDict, smaller), and a Longman Phrasal Verbs MDX (encrypt=2). 3396 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(stardict): resolve fflate from js-mdict source for vitest + Next js-mdict is consumed as TypeScript source via tsconfig paths from packages/js-mdict/src/. Its sources `import 'fflate'` directly, but fflate is only installed under apps/readest-app/node_modules — so vite's import-analysis (and Next/Turbopack's resolver) can't find fflate when it walks up from the redirected js-mdict source location. CI's fresh checkout exposes this; locally a leftover packages/js-mdict/node_modules/fflate from the old workspace setup masked it. Pin fflate resolution to apps/readest-app/node_modules/fflate in: - vitest.config.mts (Vite alias) - next.config.mjs (webpack alias + Turbopack resolveAlias — Turbopack rejects absolute paths so use a project-relative form) - tsconfig.json (paths entry so tsgo / Biome see it) Verified by deleting packages/js-mdict/node_modules locally and re-running pnpm test (3396 pass), pnpm lint (clean), and both pnpm build-web and a tauri-platform Next build (clean). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
4.4 KiB
TypeScript
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',
|
|
extensions: ['mdx', 'mdd', 'ifo', 'idx', 'dict', 'dz', 'syn'],
|
|
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;
|