feat(reader): custom dictionaries (StarDict + MDict) (#4012)

* 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>
This commit is contained in:
Huang Xin
2026-05-01 01:16:36 +08:00
committed by GitHub
parent a9684ab357
commit 5a0a70a30a
88 changed files with 5759 additions and 737 deletions
+3
View File
@@ -19,3 +19,6 @@
[submodule "packages/qcms"]
path = packages/qcms
url = https://github.com/mozilla/pdf.js.qcms.git
[submodule "packages/js-mdict"]
path = packages/js-mdict
url = https://github.com/readest/js-mdict.git
+1
View File
@@ -67,6 +67,7 @@ src-tauri/gen
/dist/
.context/
.claude/settings.local.json
.claude/skills
+12
View File
@@ -1,5 +1,9 @@
import withSerwistInit from '@serwist/next';
import withBundleAnalyzer from '@next/bundle-analyzer';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isDev = process.env['NODE_ENV'] === 'development';
const appPlatform = process.env['NEXT_PUBLIC_APP_PLATFORM'];
@@ -31,6 +35,11 @@ const nextConfig = {
config.resolve.alias = {
...config.resolve.alias,
nunjucks: 'nunjucks/browser/nunjucks.js',
// `js-mdict` is consumed as TS source via tsconfig paths from
// `packages/js-mdict/src/`; its sources `import 'fflate'` directly.
// Without an alias, webpack walks up from that source location and
// can't find fflate (only installed in this app's node_modules).
fflate: path.resolve(__dirname, 'node_modules/fflate'),
...(appPlatform !== 'web' ? { '@tursodatabase/database-wasm': false } : {}),
};
return config;
@@ -38,6 +47,9 @@ const nextConfig = {
turbopack: {
resolveAlias: {
nunjucks: 'nunjucks/browser/nunjucks.js',
// Turbopack rejects absolute paths in resolveAlias ("server relative
// imports not implemented") — use a project-relative path.
fflate: './node_modules/fflate',
...(appPlatform !== 'web' ? { '@tursodatabase/database-wasm': './src/utils/stub.ts' } : {}),
},
},
+4
View File
@@ -127,6 +127,9 @@
"ai-sdk-ollama": "^3.2.0",
"app-store-server-api": "^0.17.1",
"aws4fetch": "^1.0.20",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -134,6 +137,7 @@
"cors": "^2.8.5",
"dayjs": "^1.11.13",
"dompurify": "^3.4.0",
"fflate": "^0.8.2",
"foliate-js": "workspace:*",
"franc-min": "^6.2.0",
"fzf": "^0.5.2",
@@ -831,7 +831,6 @@
"Annotate text after selection": "تعليق على النص بعد التحديد",
"Search text after selection": "البحث في النص بعد التحديد",
"Look up text in dictionary after selection": "البحث عن النص في القاموس بعد التحديد",
"Look up text in Wikipedia after selection": "البحث عن النص في ويكيبيديا بعد التحديد",
"Translate text after selection": "ترجمة النص بعد التحديد",
"Read text aloud after selection": "قراءة النص بصوت عالٍ بعد التحديد",
"Proofread text after selection": "تدقيق النص بعد التحديد",
@@ -879,8 +878,6 @@
"Export Book": "تصدير الكتاب",
"Whole word:": "كلمة كاملة:",
"Error": "خطأ",
"Unable to load the article. Try searching directly on {{link}}.": "تعذر تحميل المقال. حاول البحث مباشرة على {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "تعذر تحميل الكلمة. حاول البحث مباشرة على {{link}}.",
"Date Published": "تاريخ النشر",
"Only for TTS:": "فقط لـ TTS:",
"Uploaded": "تم الرفع",
@@ -1171,7 +1168,6 @@
"Copy Selection": "نسخ التحديد",
"Translate Selection": "ترجمة التحديد",
"Dictionary Lookup": "البحث في القاموس",
"Wikipedia Lookup": "البحث في ويكيبيديا",
"Read Aloud Selection": "قراءة التحديد بصوت عالٍ",
"Proofread Selection": "تدقيق التحديد",
"Open Settings": "فتح الإعدادات",
@@ -1284,5 +1280,39 @@
"Computed Hash": "التجزئة المحسوبة",
"Identifiers": "المعرفات",
"Read (Stream)": "قراءة (بث)",
"Failed to start stream": "فشل بدء البث"
"Failed to start stream": "فشل بدء البث",
"No dictionaries enabled": "لا توجد قواميس مفعلة",
"Enable a dictionary in Settings → Language → Dictionaries.": "فعّل قاموسًا من الإعدادات ← اللغة ← القواميس.",
"No definitions found": "لم يتم العثور على تعريفات",
"Search for {{word}} on the web.": "ابحث عن {{word}} على الويب.",
"Dictionary unsupported": "القاموس غير مدعوم",
"This dictionary format is not supported yet.": "تنسيق هذا القاموس غير مدعوم بعد.",
"Unable to load the word.": "تعذّر تحميل الكلمة.",
"Wiktionary": "ويكاموس",
"Drag to reorder": "اسحب لإعادة الترتيب",
"Built-in": "مدمج",
"Bundle is missing on this device. Re-import to use it.": "الحزمة غير موجودة على هذا الجهاز. أعد استيرادها لاستخدامها.",
"This dictionary format is not supported.": "تنسيق هذا القاموس غير مدعوم.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_zero": "لم يتم استيراد أي قاموس",
"Imported {{count}} dictionary_one": "تم استيراد قاموس واحد ({{count}})",
"Imported {{count}} dictionary_two": "تم استيراد قاموسين",
"Imported {{count}} dictionary_few": "تم استيراد {{count}} قواميس",
"Imported {{count}} dictionary_many": "تم استيراد {{count}} قاموسًا",
"Imported {{count}} dictionary_other": "تم استيراد {{count}} قاموسًا",
"Skipped incomplete bundles: {{names}}": "تم تخطي الحزم غير المكتملة: {{names}}",
"Failed to import dictionary: {{message}}": "فشل استيراد القاموس: {{message}}",
"Dictionaries": "القواميس",
"Delete Dictionary": "حذف القاموس",
"Importing…": "جاري الاستيراد…",
"Import Dictionary": "استيراد قاموس",
"No dictionaries available.": "لا تتوفر قواميس.",
"Drag the handle on the left to reorder.": "اسحب المقبض الموجود على اليسار لإعادة الترتيب.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "تحتاج حزم StarDict إلى ملفات .ifo و‎.idx و‎.dict.dz (الـ‎.syn اختياري).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "حزم MDict تستخدم ملفات ‎.mdx؛ ملفات ‎.mdd المرافقة اختيارية.",
"Select all the bundle files together when importing.": "حدد جميع ملفات الحزمة معًا عند الاستيراد.",
"Manage Dictionaries": "إدارة القواميس",
"Select Dictionary Files": "اختر ملفات القاموس",
"Read on Wikipedia →": "اقرأ على ويكيبيديا ←"
}
@@ -791,7 +791,6 @@
"Annotate text after selection": "নির্বাচনের পরে টেক্সট মন্তব্য করুন",
"Search text after selection": "নির্বাচনের পরে টেক্সট অনুসন্ধান করুন",
"Look up text in dictionary after selection": "নির্বাচনের পরে টেক্সট অভিধানে দেখুন",
"Look up text in Wikipedia after selection": "নির্বাচনের পরে টেক্সট উইকিপিডিয়ায় দেখুন",
"Translate text after selection": "নির্বাচনের পরে টেক্সট অনুবাদ করুন",
"Read text aloud after selection": "নির্বাচনের পরে টেক্সট উচ্চারণ করুন",
"Proofread text after selection": "নির্বাচনের পরে টেক্সট প্রুফরিড করুন",
@@ -839,8 +838,6 @@
"Export Book": "বই রপ্তানি করুন",
"Whole word:": "সম্পূর্ণ শব্দ:",
"Error": "ত্রুটি",
"Unable to load the article. Try searching directly on {{link}}.": "নিবন্ধ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
"Unable to load the word. Try searching directly on {{link}}.": "শব্দ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
"Date Published": "প্রকাশনার তারিখ",
"Only for TTS:": "শুধুমাত্র TTS-এর জন্য:",
"Uploaded": "আপলোড হয়েছে",
@@ -1119,7 +1116,6 @@
"Copy Selection": "নির্বাচিত অংশ কপি করুন",
"Translate Selection": "নির্বাচিত অংশ অনুবাদ করুন",
"Dictionary Lookup": "অভিধানে খুঁজুন",
"Wikipedia Lookup": "উইকিপিডিয়ায় খুঁজুন",
"Read Aloud Selection": "নির্বাচিত অংশ জোরে পড়ুন",
"Proofread Selection": "নির্বাচিত অংশ প্রুফরিড করুন",
"Open Settings": "সেটিংস খুলুন",
@@ -1216,5 +1212,35 @@
"Computed Hash": "গণিত হ্যাশ",
"Identifiers": "শনাক্তকারী",
"Read (Stream)": "পড়ুন (স্ট্রিম)",
"Failed to start stream": "স্ট্রিম শুরু করতে ব্যর্থ"
"Failed to start stream": "স্ট্রিম শুরু করতে ব্যর্থ",
"No dictionaries enabled": "কোনো অভিধান সক্রিয় নেই",
"Enable a dictionary in Settings → Language → Dictionaries.": "সেটিংস → ভাষা → অভিধান থেকে একটি অভিধান সক্রিয় করুন।",
"No definitions found": "কোনো সংজ্ঞা পাওয়া যায়নি",
"Search for {{word}} on the web.": "ওয়েবে {{word}} খুঁজুন।",
"Dictionary unsupported": "অভিধান সমর্থিত নয়",
"This dictionary format is not supported yet.": "এই অভিধান বিন্যাস এখনো সমর্থিত নয়।",
"Unable to load the word.": "শব্দটি লোড করা যায়নি।",
"Wiktionary": "উইক্‌শনারি",
"Drag to reorder": "পুনঃক্রম করতে টানুন",
"Built-in": "বিল্ট-ইন",
"Bundle is missing on this device. Re-import to use it.": "এই ডিভাইসে বান্ডলটি নেই। ব্যবহার করতে পুনরায় আমদানি করুন।",
"This dictionary format is not supported.": "এই অভিধান বিন্যাস সমর্থিত নয়।",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "{{count}}টি অভিধান আমদানি করা হয়েছে",
"Imported {{count}} dictionary_other": "{{count}}টি অভিধান আমদানি করা হয়েছে",
"Skipped incomplete bundles: {{names}}": "অসম্পূর্ণ বান্ডল এড়ানো হয়েছে: {{names}}",
"Failed to import dictionary: {{message}}": "অভিধান আমদানি ব্যর্থ: {{message}}",
"Dictionaries": "অভিধান",
"Delete Dictionary": "অভিধান মুছুন",
"Importing…": "আমদানি হচ্ছে…",
"Import Dictionary": "অভিধান আমদানি করুন",
"No dictionaries available.": "কোনো অভিধান উপলব্ধ নেই।",
"Drag the handle on the left to reorder.": "পুনঃক্রম করতে বামদিকের হ্যান্ডেলটি টানুন।",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict বান্ডলে .ifo, .idx এবং .dict.dz ফাইল প্রয়োজন (.syn ঐচ্ছিক)।",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict বান্ডলে .mdx ফাইল ব্যবহৃত হয়; সঙ্গী .mdd ফাইল ঐচ্ছিক।",
"Select all the bundle files together when importing.": "আমদানির সময় বান্ডলের সব ফাইল একসঙ্গে নির্বাচন করুন।",
"Manage Dictionaries": "অভিধান পরিচালনা",
"Select Dictionary Files": "অভিধান ফাইল নির্বাচন করুন",
"Read on Wikipedia →": "উইকিপিডিয়ায় পড়ুন →"
}
@@ -781,7 +781,6 @@
"Annotate text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་མཆན་འགྲེལ་བྱེད་",
"Search text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་འཚོལ་ཞིབ་བྱེད་",
"Look up text in dictionary after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཚིག་མཛོད་ནང་འཚོལ་",
"Look up text in Wikipedia after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཝེ་ཀི་པི་ཌི་ཡི་ནང་འཚོལ་",
"Translate text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་སྐད་བསྒྱུར་བྱེད་",
"Read text aloud after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་སྐད་ཀྱིས་ཀློག་",
"Proofread text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཞིབ་བཤེར་བྱེད་",
@@ -829,8 +828,6 @@
"Export Book": "དཔེ་དེབ་ཕྱིར་འདོན།",
"Whole word:": "ཚིག་གྲུབ་ཆ་ཚང་:",
"Error": "ནོར་འཁྲུལ།",
"Unable to load the article. Try searching directly on {{link}}.": "རྩོམ་ཡིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
"Unable to load the word. Try searching directly on {{link}}.": "ཚིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
"Date Published": "པར་སྐྲུན་ཚེས་གྲངས།",
"Only for TTS:": "TTS ལ་ཁོ་ན།:",
"Uploaded": "ཡར་སྐྱེལ་བྱས་ཟིན།",
@@ -1106,7 +1103,6 @@
"Copy Selection": "བདམས་པ་བཤུ་བ།",
"Translate Selection": "བདམས་པ་ཡིག་སྒྱུར།",
"Dictionary Lookup": "ཚིག་མཛོད་ནང་འཚོལ་བ།",
"Wikipedia Lookup": "ཝེ་ཁེ་པི་ཌི་ཡར་འཚོལ་བ།",
"Read Aloud Selection": "བདམས་པ་སྒྲ་ཆེན་པོས་ཀློག",
"Proofread Selection": "བདམས་པ་ཞུ་དག",
"Open Settings": "སྒྲིག་འགོད་ཁ་ཕྱེ་བ།",
@@ -1199,5 +1195,34 @@
"Computed Hash": "རྩིས་པའི་ཧེཤི",
"Identifiers": "ངོས་འཛིན་བྱེད་མཁན",
"Read (Stream)": "ཀློག་པ། (རྒྱུན་སྤེལ།)",
"Failed to start stream": "རྒྱུན་སྤེལ་འགོ་འཛུགས་མ་ཐུབ།"
"Failed to start stream": "རྒྱུན་སྤེལ་འགོ་འཛུགས་མ་ཐུབ།",
"No dictionaries enabled": "ཚིག་མཛོད་གང་ཡང་སྒོ་མི་འབྱེད།",
"Enable a dictionary in Settings → Language → Dictionaries.": "སྒྲིག་འགོད ← སྐད་ཡིག ← ཚིག་མཛོད་ནས་ཚིག་མཛོད་ཤིག་ལ་སྒོ་འབྱེད།",
"No definitions found": "གོ་དོན་མི་རྙེད།",
"Search for {{word}} on the web.": "དྲ་ཐོག་ཏུ་ {{word}} ཞིབ་འཚོལ།",
"Dictionary unsupported": "ཚིག་མཛོད་འདི་རྒྱབ་སྐྱོར་མི་བྱེད།",
"This dictionary format is not supported yet.": "ཚིག་མཛོད་འདིའི་རྣམ་པར་ད་དུང་རྒྱབ་སྐྱོར་མི་བྱེད།",
"Unable to load the word.": "ཚིག་འདི་འཇུག་ཐུབ་མ་སོང༌།",
"Wiktionary": "ཝི་ཀེ་ཤོག་ནར།",
"Drag to reorder": "གོ་རིམ་སྒྱུར་བར་འཐེན།",
"Built-in": "ནང་ཁུལ།",
"Bundle is missing on this device. Re-import to use it.": "ཡོ་བྱད་འདི་དུས་ཆས་ཁྲོད་མི་འདུག འདི་སྤྱོད་པར་ཡང་བསྐྱར་ནང་འདྲེན་གནང་རོགས།",
"This dictionary format is not supported.": "ཚིག་མཛོད་འདིའི་རྣམ་པར་རྒྱབ་སྐྱོར་མི་བྱེད།",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_other": "ཚིག་མཛོད་ {{count}} ནང་འདྲེན་གྲུབ།",
"Skipped incomplete bundles: {{names}}": "ཚང་མི་འདུག་པའི་ཡོ་བྱད་སྤོས་ནས་འགྲོ་སོང༌། {{names}}",
"Failed to import dictionary: {{message}}": "ཚིག་མཛོད་ནང་འདྲེན་གྲུབ་མ་སོང༌། {{message}}",
"Dictionaries": "ཚིག་མཛོད་ཁག",
"Delete Dictionary": "ཚིག་མཛོད་སུབ།",
"Importing…": "ནང་འདྲེན་བྱེད་བཞིན་པ…",
"Import Dictionary": "ཚིག་མཛོད་ནང་འདྲེན།",
"No dictionaries available.": "ཚིག་མཛོད་གང་ཡང་མི་འདུག།",
"Drag the handle on the left to reorder.": "གོ་རིམ་སྒྱུར་བར་གཡོན་ཕྱོགས་ཀྱི་འཛིན་ཕུང་འཐེན།",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict ཡོ་བྱད་ལ་ .ifo དང་ .idx, .dict.dz ཡིག་ཆ་ཁག་དགོས་པ་ཡིན། (.syn ནི་འདེམས་འཁྱོངས་ཡིན།)",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict ཡོ་བྱད་ཀྱིས་ .mdx ཡིག་ཆ་སྤྱོད། མཉམ་འགྲོགས་ .mdd ཡིག་ཆ་ནི་འདེམས་འཁྱོངས་ཡིན།",
"Select all the bundle files together when importing.": "ནང་འདྲེན་སྐབས་ཡོ་བྱད་ཡིག་ཆ་ཡོངས་མཉམ་དུ་འདེམས་རོགས།",
"Manage Dictionaries": "ཚིག་མཛོད་སྟངས་འཛིན།",
"Select Dictionary Files": "ཚིག་མཛོད་ཡིག་ཆ་འདེམས།",
"Read on Wikipedia →": "ཝི་ཁི་ཕིཌིཡ་ནས་ཀློག →"
}
@@ -791,7 +791,6 @@
"Annotate text after selection": "Text nach Auswahl annotieren",
"Search text after selection": "Text nach Auswahl suchen",
"Look up text in dictionary after selection": "Text nach Auswahl im Wörterbuch nachschlagen",
"Look up text in Wikipedia after selection": "Text nach Auswahl in Wikipedia nachschlagen",
"Translate text after selection": "Text nach Auswahl übersetzen",
"Read text aloud after selection": "Text nach Auswahl vorlesen",
"Proofread text after selection": "Text nach Auswahl Korrektur lesen",
@@ -839,8 +838,6 @@
"Export Book": "Buch exportieren",
"Whole word:": "Ganzes Wort:",
"Error": "Fehler",
"Unable to load the article. Try searching directly on {{link}}.": "Artikel kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Wort kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
"Date Published": "Veröffentlichungsdatum",
"Only for TTS:": "Nur für TTS:",
"Uploaded": "Hochgeladen",
@@ -1119,7 +1116,6 @@
"Copy Selection": "Auswahl kopieren",
"Translate Selection": "Auswahl übersetzen",
"Dictionary Lookup": "Im Wörterbuch nachschlagen",
"Wikipedia Lookup": "In Wikipedia nachschlagen",
"Read Aloud Selection": "Auswahl vorlesen",
"Proofread Selection": "Auswahl korrekturlesen",
"Open Settings": "Einstellungen öffnen",
@@ -1216,5 +1212,35 @@
"Computed Hash": "Berechneter Hash",
"Identifiers": "Bezeichner",
"Read (Stream)": "Lesen (Stream)",
"Failed to start stream": "Stream konnte nicht gestartet werden"
"Failed to start stream": "Stream konnte nicht gestartet werden",
"No dictionaries enabled": "Keine Wörterbücher aktiviert",
"Enable a dictionary in Settings → Language → Dictionaries.": "Aktiviere ein Wörterbuch unter Einstellungen → Sprache → Wörterbücher.",
"No definitions found": "Keine Definitionen gefunden",
"Search for {{word}} on the web.": "Suche {{word}} im Web.",
"Dictionary unsupported": "Wörterbuch nicht unterstützt",
"This dictionary format is not supported yet.": "Dieses Wörterbuchformat wird noch nicht unterstützt.",
"Unable to load the word.": "Wort konnte nicht geladen werden.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Zum Neuanordnen ziehen",
"Built-in": "Integriert",
"Bundle is missing on this device. Re-import to use it.": "Bundle fehlt auf diesem Gerät. Erneut importieren, um es zu verwenden.",
"This dictionary format is not supported.": "Dieses Wörterbuchformat wird nicht unterstützt.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "{{count}} Wörterbuch importiert",
"Imported {{count}} dictionary_other": "{{count}} Wörterbücher importiert",
"Skipped incomplete bundles: {{names}}": "Unvollständige Bundles übersprungen: {{names}}",
"Failed to import dictionary: {{message}}": "Wörterbuch-Import fehlgeschlagen: {{message}}",
"Dictionaries": "Wörterbücher",
"Delete Dictionary": "Wörterbuch löschen",
"Importing…": "Wird importiert …",
"Import Dictionary": "Wörterbuch importieren",
"No dictionaries available.": "Keine Wörterbücher verfügbar.",
"Drag the handle on the left to reorder.": "Ziehe am Griff links, um die Reihenfolge zu ändern.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict-Bundles benötigen .ifo-, .idx- und .dict.dz-Dateien (.syn optional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict-Bundles verwenden .mdx-Dateien; begleitende .mdd-Dateien sind optional.",
"Select all the bundle files together when importing.": "Wähle beim Import alle Bundle-Dateien zusammen aus.",
"Manage Dictionaries": "Wörterbücher verwalten",
"Select Dictionary Files": "Wörterbuchdateien auswählen",
"Read on Wikipedia →": "Auf Wikipedia lesen →"
}
@@ -791,7 +791,6 @@
"Annotate text after selection": "Σχολιασμός κειμένου μετά την επιλογή",
"Search text after selection": "Αναζήτηση κειμένου μετά την επιλογή",
"Look up text in dictionary after selection": "Αναζήτηση κειμένου στο λεξικό μετά την επιλογή",
"Look up text in Wikipedia after selection": "Αναζήτηση κειμένου στη Wikipedia μετά την επιλογή",
"Translate text after selection": "Μετάφραση κειμένου μετά την επιλογή",
"Read text aloud after selection": "Ανάγνωση κειμένου μετά την επιλογή",
"Proofread text after selection": "Διόρθωση κειμένου μετά την επιλογή",
@@ -839,8 +838,6 @@
"Export Book": "Εξαγωγή βιβλίου",
"Whole word:": "Ολόκληρη λέξη:",
"Error": "Σφάλμα",
"Unable to load the article. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης του άρθρου. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης της λέξης. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
"Date Published": "Ημερομηνία δημοσίευσης",
"Only for TTS:": "Μόνο για TTS:",
"Uploaded": "Μεταφορτώθηκε",
@@ -1119,7 +1116,6 @@
"Copy Selection": "Αντιγραφή επιλογής",
"Translate Selection": "Μετάφραση επιλογής",
"Dictionary Lookup": "Αναζήτηση στο λεξικό",
"Wikipedia Lookup": "Αναζήτηση στη Wikipedia",
"Read Aloud Selection": "Ανάγνωση επιλογής",
"Proofread Selection": "Διόρθωση επιλογής",
"Open Settings": "Άνοιγμα ρυθμίσεων",
@@ -1216,5 +1212,35 @@
"Computed Hash": "Υπολογισμένο Hash",
"Identifiers": "Αναγνωριστικά",
"Read (Stream)": "Ανάγνωση (Ροή)",
"Failed to start stream": "Αποτυχία έναρξης ροής"
"Failed to start stream": "Αποτυχία έναρξης ροής",
"No dictionaries enabled": "Δεν είναι ενεργοποιημένο κανένα λεξικό",
"Enable a dictionary in Settings → Language → Dictionaries.": "Ενεργοποιήστε ένα λεξικό από Ρυθμίσεις → Γλώσσα → Λεξικά.",
"No definitions found": "Δεν βρέθηκαν ορισμοί",
"Search for {{word}} on the web.": "Αναζήτηση {{word}} στον ιστό.",
"Dictionary unsupported": "Το λεξικό δεν υποστηρίζεται",
"This dictionary format is not supported yet.": "Αυτή η μορφή λεξικού δεν υποστηρίζεται ακόμα.",
"Unable to load the word.": "Δεν ήταν δυνατή η φόρτωση της λέξης.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Σύρετε για επαναταξινόμηση",
"Built-in": "Ενσωματωμένο",
"Bundle is missing on this device. Re-import to use it.": "Το πακέτο λείπει από αυτή τη συσκευή. Επανεισαγάγετέ το για χρήση.",
"This dictionary format is not supported.": "Αυτή η μορφή λεξικού δεν υποστηρίζεται.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "Εισήχθη {{count}} λεξικό",
"Imported {{count}} dictionary_other": "Εισήχθησαν {{count}} λεξικά",
"Skipped incomplete bundles: {{names}}": "Παραλείφθηκαν ημιτελή πακέτα: {{names}}",
"Failed to import dictionary: {{message}}": "Αποτυχία εισαγωγής λεξικού: {{message}}",
"Dictionaries": "Λεξικά",
"Delete Dictionary": "Διαγραφή λεξικού",
"Importing…": "Εισαγωγή…",
"Import Dictionary": "Εισαγωγή λεξικού",
"No dictionaries available.": "Δεν υπάρχουν διαθέσιμα λεξικά.",
"Drag the handle on the left to reorder.": "Σύρετε τη λαβή αριστερά για επαναταξινόμηση.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Τα πακέτα StarDict χρειάζονται αρχεία .ifo, .idx και .dict.dz (το .syn είναι προαιρετικό).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Τα πακέτα MDict χρησιμοποιούν αρχεία .mdx· τα συνοδευτικά .mdd είναι προαιρετικά.",
"Select all the bundle files together when importing.": "Επιλέξτε όλα τα αρχεία του πακέτου μαζί κατά την εισαγωγή.",
"Manage Dictionaries": "Διαχείριση λεξικών",
"Select Dictionary Files": "Επιλογή αρχείων λεξικού",
"Read on Wikipedia →": "Διάβασέ το στη Wikipedia →"
}
@@ -801,7 +801,6 @@
"Annotate text after selection": "Anotar texto después de la selección",
"Search text after selection": "Buscar texto después de la selección",
"Look up text in dictionary after selection": "Buscar texto en el diccionario después de la selección",
"Look up text in Wikipedia after selection": "Buscar texto en Wikipedia después de la selección",
"Translate text after selection": "Traducir texto después de la selección",
"Read text aloud after selection": "Leer texto en voz alta después de la selección",
"Proofread text after selection": "Corregir texto después de la selección",
@@ -849,8 +848,6 @@
"Export Book": "Exportar libro",
"Whole word:": "Palabra completa:",
"Error": "Error",
"Unable to load the article. Try searching directly on {{link}}.": "No se puede cargar el artículo. Intenta buscar directamente en {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "No se puede cargar la palabra. Intenta buscar directamente en {{link}}.",
"Date Published": "Fecha de publicación",
"Only for TTS:": "Solo para TTS:",
"Uploaded": "Subido",
@@ -1132,7 +1129,6 @@
"Copy Selection": "Copiar selección",
"Translate Selection": "Traducir selección",
"Dictionary Lookup": "Buscar en diccionario",
"Wikipedia Lookup": "Buscar en Wikipedia",
"Read Aloud Selection": "Leer selección en voz alta",
"Proofread Selection": "Corregir selección",
"Open Settings": "Abrir ajustes",
@@ -1233,5 +1229,36 @@
"Computed Hash": "Hash Calculado",
"Identifiers": "Identificadores",
"Read (Stream)": "Leer (Streaming)",
"Failed to start stream": "No se pudo iniciar el streaming"
"Failed to start stream": "No se pudo iniciar el streaming",
"No dictionaries enabled": "No hay diccionarios habilitados",
"Enable a dictionary in Settings → Language → Dictionaries.": "Habilita un diccionario en Ajustes → Idioma → Diccionarios.",
"No definitions found": "No se encontraron definiciones",
"Search for {{word}} on the web.": "Buscar {{word}} en la web.",
"Dictionary unsupported": "Diccionario no compatible",
"This dictionary format is not supported yet.": "Este formato de diccionario aún no es compatible.",
"Unable to load the word.": "No se pudo cargar la palabra.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Arrastrar para reordenar",
"Built-in": "Integrado",
"Bundle is missing on this device. Re-import to use it.": "El paquete falta en este dispositivo. Vuelve a importarlo para usarlo.",
"This dictionary format is not supported.": "Este formato de diccionario no es compatible.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "Se importó {{count}} diccionario",
"Imported {{count}} dictionary_many": "Se importaron {{count}} diccionarios",
"Imported {{count}} dictionary_other": "Se importaron {{count}} diccionarios",
"Skipped incomplete bundles: {{names}}": "Se omitieron paquetes incompletos: {{names}}",
"Failed to import dictionary: {{message}}": "Error al importar el diccionario: {{message}}",
"Dictionaries": "Diccionarios",
"Delete Dictionary": "Eliminar diccionario",
"Importing…": "Importando…",
"Import Dictionary": "Importar diccionario",
"No dictionaries available.": "No hay diccionarios disponibles.",
"Drag the handle on the left to reorder.": "Arrastra el asa de la izquierda para reordenar.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Los paquetes StarDict requieren archivos .ifo, .idx y .dict.dz (.syn opcional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Los paquetes MDict usan archivos .mdx; los .mdd asociados son opcionales.",
"Select all the bundle files together when importing.": "Selecciona todos los archivos del paquete juntos al importar.",
"Manage Dictionaries": "Administrar diccionarios",
"Select Dictionary Files": "Seleccionar archivos de diccionario",
"Read on Wikipedia →": "Leer en Wikipedia →"
}
@@ -791,7 +791,6 @@
"Annotate text after selection": "یادداشت‌گذاری متن پس از انتخاب",
"Search text after selection": "جستجوی متن پس از انتخاب",
"Look up text in dictionary after selection": "جستجوی متن در فرهنگ لغت پس از انتخاب",
"Look up text in Wikipedia after selection": "جستجوی متن در ویکی‌پدیا پس از انتخاب",
"Translate text after selection": "ترجمه متن پس از انتخاب",
"Read text aloud after selection": "خواندن متن با صدای بلند پس از انتخاب",
"Proofread text after selection": "بازبینی متن پس از انتخاب",
@@ -839,8 +838,6 @@
"Export Book": "صادر کردن کتاب",
"Whole word:": "کلمه کامل:",
"Error": "خطا",
"Unable to load the article. Try searching directly on {{link}}.": "بارگذاری مقاله امکان‌پذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
"Unable to load the word. Try searching directly on {{link}}.": "بارگذاری کلمه امکان‌پذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
"Date Published": "تاریخ انتشار",
"Only for TTS:": "فقط برای TTS:",
"Uploaded": "بارگذاری شد",
@@ -1119,7 +1116,6 @@
"Copy Selection": "کپی انتخاب",
"Translate Selection": "ترجمه انتخاب",
"Dictionary Lookup": "جستجو در فرهنگ لغت",
"Wikipedia Lookup": "جستجو در ویکی‌پدیا",
"Read Aloud Selection": "خواندن بلند انتخاب",
"Proofread Selection": "بازبینی انتخاب",
"Open Settings": "باز کردن تنظیمات",
@@ -1216,5 +1212,35 @@
"Computed Hash": "هش محاسبه‌شده",
"Identifiers": "شناسه‌ها",
"Read (Stream)": "خواندن (پخش)",
"Failed to start stream": "شروع پخش ناموفق بود"
"Failed to start stream": "شروع پخش ناموفق بود",
"No dictionaries enabled": "هیچ فرهنگ‌لغتی فعال نیست",
"Enable a dictionary in Settings → Language → Dictionaries.": "فرهنگ‌لغتی را از تنظیمات ← زبان ← فرهنگ‌لغت‌ها فعال کنید.",
"No definitions found": "هیچ معنی‌ای یافت نشد",
"Search for {{word}} on the web.": "جست‌وجوی {{word}} در وب.",
"Dictionary unsupported": "فرهنگ‌لغت پشتیبانی نمی‌شود",
"This dictionary format is not supported yet.": "این قالب فرهنگ‌لغت هنوز پشتیبانی نمی‌شود.",
"Unable to load the word.": "بارگذاری واژه ممکن نشد.",
"Wiktionary": "ویکی‌واژه",
"Drag to reorder": "برای تغییر ترتیب بکشید",
"Built-in": "داخلی",
"Bundle is missing on this device. Re-import to use it.": "بسته در این دستگاه موجود نیست. برای استفاده دوباره وارد کنید.",
"This dictionary format is not supported.": "این قالب فرهنگ‌لغت پشتیبانی نمی‌شود.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "{{count}} فرهنگ‌لغت وارد شد",
"Imported {{count}} dictionary_other": "{{count}} فرهنگ‌لغت وارد شد",
"Skipped incomplete bundles: {{names}}": "بسته‌های ناقص رد شدند: {{names}}",
"Failed to import dictionary: {{message}}": "وارد کردن فرهنگ‌لغت ناموفق بود: {{message}}",
"Dictionaries": "فرهنگ‌لغت‌ها",
"Delete Dictionary": "حذف فرهنگ‌لغت",
"Importing…": "در حال وارد کردن…",
"Import Dictionary": "وارد کردن فرهنگ‌لغت",
"No dictionaries available.": "هیچ فرهنگ‌لغتی در دسترس نیست.",
"Drag the handle on the left to reorder.": "برای تغییر ترتیب، دستگیرهٔ سمت چپ را بکشید.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "بسته‌های StarDict به فایل‌های .ifo، .idx و .dict.dz نیاز دارند (.syn اختیاری).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "بسته‌های MDict از فایل‌های .mdx استفاده می‌کنند؛ فایل‌های همراه .mdd اختیاری‌اند.",
"Select all the bundle files together when importing.": "هنگام وارد کردن، همه فایل‌های بسته را با هم انتخاب کنید.",
"Manage Dictionaries": "مدیریت فرهنگ‌لغت‌ها",
"Select Dictionary Files": "انتخاب فایل‌های فرهنگ‌لغت",
"Read on Wikipedia →": "در ویکی‌پدیا بخوانید →"
}
@@ -801,7 +801,6 @@
"Annotate text after selection": "Annoter le texte après la sélection",
"Search text after selection": "Rechercher le texte après la sélection",
"Look up text in dictionary after selection": "Chercher le texte dans le dictionnaire après la sélection",
"Look up text in Wikipedia after selection": "Chercher le texte dans Wikipédia après la sélection",
"Translate text after selection": "Traduire le texte après la sélection",
"Read text aloud after selection": "Lire le texte à haute voix après la sélection",
"Proofread text after selection": "Relire le texte après la sélection",
@@ -849,8 +848,6 @@
"Export Book": "Exporter le livre",
"Whole word:": "Mot entier :",
"Error": "Erreur",
"Unable to load the article. Try searching directly on {{link}}.": "Impossible de charger l'article. Essayez de rechercher directement sur {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Impossible de charger le mot. Essayez de rechercher directement sur {{link}}.",
"Date Published": "Date de publication",
"Only for TTS:": "Uniquement pour TTS :",
"Uploaded": "Téléversé",
@@ -1132,7 +1129,6 @@
"Copy Selection": "Copier la sélection",
"Translate Selection": "Traduire la sélection",
"Dictionary Lookup": "Recherche dans le dictionnaire",
"Wikipedia Lookup": "Recherche sur Wikipédia",
"Read Aloud Selection": "Lire la sélection à voix haute",
"Proofread Selection": "Relire la sélection",
"Open Settings": "Ouvrir les paramètres",
@@ -1233,5 +1229,36 @@
"Computed Hash": "Hachage calculé",
"Identifiers": "Identifiants",
"Read (Stream)": "Lire (Flux)",
"Failed to start stream": "Échec du démarrage du flux"
"Failed to start stream": "Échec du démarrage du flux",
"No dictionaries enabled": "Aucun dictionnaire activé",
"Enable a dictionary in Settings → Language → Dictionaries.": "Activez un dictionnaire dans Paramètres → Langue → Dictionnaires.",
"No definitions found": "Aucune définition trouvée",
"Search for {{word}} on the web.": "Rechercher {{word}} sur le web.",
"Dictionary unsupported": "Dictionnaire non pris en charge",
"This dictionary format is not supported yet.": "Ce format de dictionnaire nest pas encore pris en charge.",
"Unable to load the word.": "Impossible de charger le mot.",
"Wiktionary": "Wiktionnaire",
"Drag to reorder": "Glisser pour réorganiser",
"Built-in": "Intégré",
"Bundle is missing on this device. Re-import to use it.": "Le paquet est absent sur cet appareil. Ré-importez-le pour lutiliser.",
"This dictionary format is not supported.": "Ce format de dictionnaire nest pas pris en charge.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "{{count}} dictionnaire importé",
"Imported {{count}} dictionary_many": "{{count}} dictionnaires importés",
"Imported {{count}} dictionary_other": "{{count}} dictionnaires importés",
"Skipped incomplete bundles: {{names}}": "Paquets incomplets ignorés : {{names}}",
"Failed to import dictionary: {{message}}": "Échec de limport du dictionnaire : {{message}}",
"Dictionaries": "Dictionnaires",
"Delete Dictionary": "Supprimer le dictionnaire",
"Importing…": "Importation…",
"Import Dictionary": "Importer un dictionnaire",
"No dictionaries available.": "Aucun dictionnaire disponible.",
"Drag the handle on the left to reorder.": "Glissez la poignée à gauche pour réorganiser.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Les paquets StarDict nécessitent des fichiers .ifo, .idx et .dict.dz (.syn optionnel).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Les paquets MDict utilisent des fichiers .mdx ; les .mdd associés sont optionnels.",
"Select all the bundle files together when importing.": "Sélectionnez tous les fichiers du paquet ensemble lors de limport.",
"Manage Dictionaries": "Gérer les dictionnaires",
"Select Dictionary Files": "Sélectionner les fichiers du dictionnaire",
"Read on Wikipedia →": "Lire sur Wikipédia →"
}
@@ -291,7 +291,6 @@
"Dictionary": "מילון",
"Look up text in dictionary after selection": "חפש טקסט במילון לאחר הבחירה",
"Wikipedia": "ויקיפדיה",
"Look up text in Wikipedia after selection": "חפש טקסט בוויקיפדיה לאחר הבחירה",
"Translate": "תרגם",
"Translate text after selection": "תרגם טקסט לאחר הבחירה",
"Speak": "הקרא",
@@ -390,8 +389,6 @@
"No translation available.": "אין תרגום זמין.",
"Translated by {{provider}}.": "תורגם על ידי {{provider}}.",
"Error": "שגיאה",
"Unable to load the article. Try searching directly on {{link}}.": "לא ניתן לטעון את המאמר. נסה לחפש ישירות ב-{{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "לא ניתן לטעון את המילה. נסה לחפש ישירות ב-{{link}}.",
"Remove Bookmark": "הסר סימנייה",
"Add Bookmark": "הוסף סימנייה",
"Books Content": "תוכן ספרים",
@@ -1132,7 +1129,6 @@
"Copy Selection": "העתק בחירה",
"Translate Selection": "תרגם בחירה",
"Dictionary Lookup": "חיפוש במילון",
"Wikipedia Lookup": "חיפוש בוויקיפדיה",
"Read Aloud Selection": "קרא בקול את הבחירה",
"Proofread Selection": "הגה בחירה",
"Open Settings": "פתח הגדרות",
@@ -1233,5 +1229,36 @@
"Computed Hash": "גיבוב מחושב",
"Identifiers": "מזהים",
"Read (Stream)": "קרא (זרם)",
"Failed to start stream": "הפעלת הזרם נכשלה"
"Failed to start stream": "הפעלת הזרם נכשלה",
"No dictionaries enabled": "לא הופעלו מילונים",
"Enable a dictionary in Settings → Language → Dictionaries.": "הפעילו מילון מההגדרות ← שפה ← מילונים.",
"No definitions found": "לא נמצאו הגדרות",
"Search for {{word}} on the web.": "חיפוש {{word}} באינטרנט.",
"Dictionary unsupported": "מילון לא נתמך",
"This dictionary format is not supported yet.": "תבנית המילון הזו אינה נתמכת עדיין.",
"Unable to load the word.": "לא ניתן לטעון את המילה.",
"Wiktionary": "ויקימילון",
"Drag to reorder": "גררו כדי לסדר מחדש",
"Built-in": "מובנה",
"Bundle is missing on this device. Re-import to use it.": "החבילה חסרה במכשיר הזה. ייבאו מחדש כדי להשתמש בה.",
"This dictionary format is not supported.": "תבנית המילון הזו אינה נתמכת.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "יובא {{count}} מילון",
"Imported {{count}} dictionary_two": "יובאו שני מילונים",
"Imported {{count}} dictionary_other": "יובאו {{count}} מילונים",
"Skipped incomplete bundles: {{names}}": "דולגו על חבילות לא שלמות: {{names}}",
"Failed to import dictionary: {{message}}": "ייבוא המילון נכשל: {{message}}",
"Dictionaries": "מילונים",
"Delete Dictionary": "מחק מילון",
"Importing…": "מייבא…",
"Import Dictionary": "ייבא מילון",
"No dictionaries available.": "אין מילונים זמינים.",
"Drag the handle on the left to reorder.": "גררו את הידית משמאל כדי לסדר מחדש.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "חבילות StarDict דורשות קבצי .ifo, .idx ו-.dict.dz (.syn אופציונלי).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "חבילות MDict משתמשות בקבצי .mdx; קבצי .mdd נלווים הם אופציונליים.",
"Select all the bundle files together when importing.": "בחרו את כל קבצי החבילה יחד בעת הייבוא.",
"Manage Dictionaries": "ניהול מילונים",
"Select Dictionary Files": "בחירת קבצי מילון",
"Read on Wikipedia →": "קראו בוויקיפדיה →"
}
@@ -791,7 +791,6 @@
"Annotate text after selection": "चयन के बाद पाठ पर टिप्पणी करें",
"Search text after selection": "चयन के बाद पाठ खोजें",
"Look up text in dictionary after selection": "चयन के बाद शब्दकोश में पाठ देखें",
"Look up text in Wikipedia after selection": "चयन के बाद विकिपीडिया में पाठ देखें",
"Translate text after selection": "चयन के बाद पाठ का अनुवाद करें",
"Read text aloud after selection": "चयन के बाद पाठ को जोर से पढ़ें",
"Proofread text after selection": "चयन के बाद पाठ को प्रूफरीड करें",
@@ -839,8 +838,6 @@
"Export Book": "पुस्तक निर्यात करें",
"Whole word:": "पूरा शब्द:",
"Error": "त्रुटि",
"Unable to load the article. Try searching directly on {{link}}.": "लेख लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
"Unable to load the word. Try searching directly on {{link}}.": "शब्द लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
"Date Published": "प्रकाशन तिथि",
"Only for TTS:": "केवल TTS के लिए:",
"Uploaded": "अपलोड किया गया",
@@ -1119,7 +1116,6 @@
"Copy Selection": "चयन कॉपी करें",
"Translate Selection": "चयन का अनुवाद करें",
"Dictionary Lookup": "शब्दकोश में खोजें",
"Wikipedia Lookup": "विकिपीडिया पर खोजें",
"Read Aloud Selection": "चयन को ज़ोर से पढ़ें",
"Proofread Selection": "चयन की प्रूफरीडिंग करें",
"Open Settings": "सेटिंग्स खोलें",
@@ -1216,5 +1212,35 @@
"Computed Hash": "परिकलित हैश",
"Identifiers": "पहचानकर्ता",
"Read (Stream)": "पढ़ें (स्ट्रीम)",
"Failed to start stream": "स्ट्रीम प्रारंभ करने में विफल"
"Failed to start stream": "स्ट्रीम प्रारंभ करने में विफल",
"No dictionaries enabled": "कोई शब्दकोश सक्षम नहीं है",
"Enable a dictionary in Settings → Language → Dictionaries.": "सेटिंग्स → भाषा → शब्दकोश में जाकर शब्दकोश सक्षम करें।",
"No definitions found": "कोई परिभाषा नहीं मिली",
"Search for {{word}} on the web.": "वेब पर {{word}} खोजें।",
"Dictionary unsupported": "शब्दकोश समर्थित नहीं है",
"This dictionary format is not supported yet.": "यह शब्दकोश प्रारूप अभी समर्थित नहीं है।",
"Unable to load the word.": "शब्द लोड नहीं किया जा सका।",
"Wiktionary": "विक्षनरी",
"Drag to reorder": "पुनः क्रमित करने के लिए खींचें",
"Built-in": "अंतर्निर्मित",
"Bundle is missing on this device. Re-import to use it.": "इस डिवाइस पर बंडल मौजूद नहीं है। उपयोग के लिए पुनः आयात करें।",
"This dictionary format is not supported.": "यह शब्दकोश प्रारूप समर्थित नहीं है।",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "{{count}} शब्दकोश आयात किया गया",
"Imported {{count}} dictionary_other": "{{count}} शब्दकोश आयात किए गए",
"Skipped incomplete bundles: {{names}}": "अधूरे बंडल छोड़े गए: {{names}}",
"Failed to import dictionary: {{message}}": "शब्दकोश आयात विफल: {{message}}",
"Dictionaries": "शब्दकोश",
"Delete Dictionary": "शब्दकोश हटाएँ",
"Importing…": "आयात हो रहा है…",
"Import Dictionary": "शब्दकोश आयात करें",
"No dictionaries available.": "कोई शब्दकोश उपलब्ध नहीं है।",
"Drag the handle on the left to reorder.": "पुनः क्रमित करने के लिए बायीं ओर के हैंडल को खींचें।",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict बंडल को .ifo, .idx और .dict.dz फ़ाइलें चाहिए (.syn वैकल्पिक)।",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict बंडल .mdx फ़ाइलों का उपयोग करते हैं; साथी .mdd फ़ाइलें वैकल्पिक हैं।",
"Select all the bundle files together when importing.": "आयात करते समय बंडल की सभी फ़ाइलें एक साथ चुनें।",
"Manage Dictionaries": "शब्दकोश प्रबंधित करें",
"Select Dictionary Files": "शब्दकोश फ़ाइलें चुनें",
"Read on Wikipedia →": "विकिपीडिया पर पढ़ें →"
}
@@ -306,7 +306,6 @@
"Dictionary": "Szótár",
"Look up text in dictionary after selection": "Szöveg kikeresése szótárban kijelölés után",
"Wikipedia": "Wikipédia",
"Look up text in Wikipedia after selection": "Szöveg kikeresése a Wikipédián kijelölés után",
"Translate": "Fordítás",
"Translate text after selection": "Szöveg fordítása kijelölés után",
"Speak": "Felolvasás",
@@ -421,8 +420,6 @@
"No translation available.": "Nem érhető el fordítás.",
"Translated by {{provider}}.": "Fordította: {{provider}}.",
"Error": "Hiba",
"Unable to load the article. Try searching directly on {{link}}.": "A cikk nem tölthető be. Próbáljon közvetlenül keresni a {{link}} oldalon.",
"Unable to load the word. Try searching directly on {{link}}.": "A szó nem tölthető be. Próbáljon közvetlenül keresni a {{link}} oldalon.",
"Remove Bookmark": "Könyvjelző eltávolítása",
"Add Bookmark": "Könyvjelző hozzáadása",
"Books Content": "Könyvek tartalma",
@@ -1105,7 +1102,6 @@
"Copy Selection": "Kijelölés másolása",
"Translate Selection": "Kijelölés fordítása",
"Dictionary Lookup": "Szótári keresés",
"Wikipedia Lookup": "Wikipedia keresés",
"Read Aloud Selection": "Kijelölés felolvasása",
"Proofread Selection": "Kijelölés lektorálása",
"Open Settings": "Beállítások megnyitása",
@@ -1216,5 +1212,35 @@
"Computed Hash": "Számított hash",
"Identifiers": "Azonosítók",
"Read (Stream)": "Olvasás (Stream)",
"Failed to start stream": "Nem sikerült elindítani a streamet"
"Failed to start stream": "Nem sikerült elindítani a streamet",
"No dictionaries enabled": "Nincs engedélyezett szótár",
"Enable a dictionary in Settings → Language → Dictionaries.": "Engedélyezz egy szótárt a Beállítások → Nyelv → Szótárak menüben.",
"No definitions found": "Nem található meghatározás",
"Search for {{word}} on the web.": "{{word}} keresése a weben.",
"Dictionary unsupported": "A szótár nem támogatott",
"This dictionary format is not supported yet.": "Ez a szótárformátum még nem támogatott.",
"Unable to load the word.": "A szó nem tölthető be.",
"Wiktionary": "Wikiszótár",
"Drag to reorder": "Húzd az átrendezéshez",
"Built-in": "Beépített",
"Bundle is missing on this device. Re-import to use it.": "A csomag hiányzik erről az eszközről. Importáld újra a használatához.",
"This dictionary format is not supported.": "Ez a szótárformátum nem támogatott.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "{{count}} szótár importálva",
"Imported {{count}} dictionary_other": "{{count}} szótár importálva",
"Skipped incomplete bundles: {{names}}": "Hiányos csomagok kihagyva: {{names}}",
"Failed to import dictionary: {{message}}": "A szótár importálása sikertelen: {{message}}",
"Dictionaries": "Szótárak",
"Delete Dictionary": "Szótár törlése",
"Importing…": "Importálás…",
"Import Dictionary": "Szótár importálása",
"No dictionaries available.": "Nincsenek elérhető szótárak.",
"Drag the handle on the left to reorder.": "A bal oldali fogantyúval rendezheted át.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "A StarDict csomagokhoz .ifo, .idx és .dict.dz fájlok szükségesek (.syn opcionális).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Az MDict csomagok .mdx fájlokat használnak; a kísérő .mdd fájlok opcionálisak.",
"Select all the bundle files together when importing.": "Importáláskor jelöld ki egyszerre a csomag összes fájlját.",
"Manage Dictionaries": "Szótárak kezelése",
"Select Dictionary Files": "Válassz szótárfájlokat",
"Read on Wikipedia →": "Olvasd el a Wikipédián →"
}
@@ -781,7 +781,6 @@
"Annotate text after selection": "Anotasi teks setelah pemilihan",
"Search text after selection": "Cari teks setelah pemilihan",
"Look up text in dictionary after selection": "Cari teks di kamus setelah pemilihan",
"Look up text in Wikipedia after selection": "Cari teks di Wikipedia setelah pemilihan",
"Translate text after selection": "Terjemahkan teks setelah pemilihan",
"Read text aloud after selection": "Bacakan teks setelah pemilihan",
"Proofread text after selection": "Periksa teks setelah pemilihan",
@@ -829,8 +828,6 @@
"Export Book": "Ekspor Buku",
"Whole word:": "Kata utuh:",
"Error": "Kesalahan",
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuat artikel. Coba cari langsung di {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuat kata. Coba cari langsung di {{link}}.",
"Date Published": "Tanggal terbit",
"Only for TTS:": "Hanya untuk TTS:",
"Uploaded": "Diunggah",
@@ -1106,7 +1103,6 @@
"Copy Selection": "Salin pilihan",
"Translate Selection": "Terjemahkan pilihan",
"Dictionary Lookup": "Cari di kamus",
"Wikipedia Lookup": "Cari di Wikipedia",
"Read Aloud Selection": "Bacakan pilihan",
"Proofread Selection": "Periksa ejaan pilihan",
"Open Settings": "Buka pengaturan",
@@ -1199,5 +1195,34 @@
"Computed Hash": "Hash Terhitung",
"Identifiers": "Pengenal",
"Read (Stream)": "Baca (Stream)",
"Failed to start stream": "Gagal memulai stream"
"Failed to start stream": "Gagal memulai stream",
"No dictionaries enabled": "Tidak ada kamus yang diaktifkan",
"Enable a dictionary in Settings → Language → Dictionaries.": "Aktifkan kamus di Pengaturan → Bahasa → Kamus.",
"No definitions found": "Tidak ada definisi yang ditemukan",
"Search for {{word}} on the web.": "Cari {{word}} di web.",
"Dictionary unsupported": "Kamus tidak didukung",
"This dictionary format is not supported yet.": "Format kamus ini belum didukung.",
"Unable to load the word.": "Tidak dapat memuat kata.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Seret untuk mengurutkan ulang",
"Built-in": "Bawaan",
"Bundle is missing on this device. Re-import to use it.": "Paket tidak ada di perangkat ini. Impor ulang untuk menggunakannya.",
"This dictionary format is not supported.": "Format kamus ini tidak didukung.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_other": "{{count}} kamus diimpor",
"Skipped incomplete bundles: {{names}}": "Paket tidak lengkap dilewati: {{names}}",
"Failed to import dictionary: {{message}}": "Gagal mengimpor kamus: {{message}}",
"Dictionaries": "Kamus",
"Delete Dictionary": "Hapus Kamus",
"Importing…": "Mengimpor…",
"Import Dictionary": "Impor Kamus",
"No dictionaries available.": "Tidak ada kamus yang tersedia.",
"Drag the handle on the left to reorder.": "Seret pegangan di sebelah kiri untuk mengurutkan ulang.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Paket StarDict memerlukan file .ifo, .idx, dan .dict.dz (.syn opsional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Paket MDict menggunakan file .mdx; file .mdd pendamping bersifat opsional.",
"Select all the bundle files together when importing.": "Pilih semua file paket sekaligus saat mengimpor.",
"Manage Dictionaries": "Kelola Kamus",
"Select Dictionary Files": "Pilih File Kamus",
"Read on Wikipedia →": "Baca di Wikipedia →"
}
@@ -801,7 +801,6 @@
"Annotate text after selection": "Annota testo dopo la selezione",
"Search text after selection": "Cerca testo dopo la selezione",
"Look up text in dictionary after selection": "Cerca testo nel dizionario dopo la selezione",
"Look up text in Wikipedia after selection": "Cerca testo in Wikipedia dopo la selezione",
"Translate text after selection": "Traduci testo dopo la selezione",
"Read text aloud after selection": "Leggi ad alta voce il testo dopo la selezione",
"Proofread text after selection": "Correggi il testo dopo la selezione",
@@ -849,8 +848,6 @@
"Export Book": "Esporta libro",
"Whole word:": "Parola intera:",
"Error": "Errore",
"Unable to load the article. Try searching directly on {{link}}.": "Impossibile caricare l'articolo. Prova a cercare direttamente su {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Impossibile caricare la parola. Prova a cercare direttamente su {{link}}.",
"Date Published": "Data di pubblicazione",
"Only for TTS:": "Solo per TTS:",
"Uploaded": "Caricato",
@@ -1132,7 +1129,6 @@
"Copy Selection": "Copia selezione",
"Translate Selection": "Traduci selezione",
"Dictionary Lookup": "Cerca nel dizionario",
"Wikipedia Lookup": "Cerca su Wikipedia",
"Read Aloud Selection": "Leggi la selezione ad alta voce",
"Proofread Selection": "Correggi selezione",
"Open Settings": "Apri impostazioni",
@@ -1233,5 +1229,36 @@
"Computed Hash": "Hash Calcolato",
"Identifiers": "Identificatori",
"Read (Stream)": "Leggi (Stream)",
"Failed to start stream": "Avvio dello stream non riuscito"
"Failed to start stream": "Avvio dello stream non riuscito",
"No dictionaries enabled": "Nessun dizionario abilitato",
"Enable a dictionary in Settings → Language → Dictionaries.": "Abilita un dizionario in Impostazioni → Lingua → Dizionari.",
"No definitions found": "Nessuna definizione trovata",
"Search for {{word}} on the web.": "Cerca {{word}} sul web.",
"Dictionary unsupported": "Dizionario non supportato",
"This dictionary format is not supported yet.": "Questo formato di dizionario non è ancora supportato.",
"Unable to load the word.": "Impossibile caricare la parola.",
"Wiktionary": "Wikizionario",
"Drag to reorder": "Trascina per riordinare",
"Built-in": "Integrato",
"Bundle is missing on this device. Re-import to use it.": "Il pacchetto manca su questo dispositivo. Re-importalo per usarlo.",
"This dictionary format is not supported.": "Questo formato di dizionario non è supportato.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "Importato {{count}} dizionario",
"Imported {{count}} dictionary_many": "Importati {{count}} dizionari",
"Imported {{count}} dictionary_other": "Importati {{count}} dizionari",
"Skipped incomplete bundles: {{names}}": "Pacchetti incompleti ignorati: {{names}}",
"Failed to import dictionary: {{message}}": "Importazione del dizionario non riuscita: {{message}}",
"Dictionaries": "Dizionari",
"Delete Dictionary": "Elimina dizionario",
"Importing…": "Importazione…",
"Import Dictionary": "Importa dizionario",
"No dictionaries available.": "Nessun dizionario disponibile.",
"Drag the handle on the left to reorder.": "Trascina la maniglia a sinistra per riordinare.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "I pacchetti StarDict richiedono file .ifo, .idx e .dict.dz (.syn opzionale).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "I pacchetti MDict usano file .mdx; i .mdd associati sono opzionali.",
"Select all the bundle files together when importing.": "Seleziona tutti i file del pacchetto insieme durante limportazione.",
"Manage Dictionaries": "Gestisci dizionari",
"Select Dictionary Files": "Seleziona file del dizionario",
"Read on Wikipedia →": "Leggi su Wikipedia →"
}
@@ -781,7 +781,6 @@
"Annotate text after selection": "選択後のテキストに注釈を付ける",
"Search text after selection": "選択後のテキストを検索",
"Look up text in dictionary after selection": "選択後のテキストを辞書で調べる",
"Look up text in Wikipedia after selection": "選択後のテキストをWikipediaで調べる",
"Translate text after selection": "選択後のテキストを翻訳する",
"Read text aloud after selection": "選択後のテキストを音読する",
"Proofread text after selection": "選択後のテキストを校正する",
@@ -829,8 +828,6 @@
"Export Book": "書籍をエクスポート",
"Whole word:": "単語全体:",
"Error": "エラー",
"Unable to load the article. Try searching directly on {{link}}.": "記事を読み込めません。{{link}}で直接検索してみてください。",
"Unable to load the word. Try searching directly on {{link}}.": "単語を読み込めません。{{link}}で直接検索してみてください。",
"Date Published": "出版日",
"Only for TTS:": "TTSのみ:",
"Uploaded": "アップロード済み",
@@ -1106,7 +1103,6 @@
"Copy Selection": "選択範囲をコピー",
"Translate Selection": "選択範囲を翻訳",
"Dictionary Lookup": "辞書で調べる",
"Wikipedia Lookup": "Wikipediaで調べる",
"Read Aloud Selection": "選択範囲を読み上げ",
"Proofread Selection": "選択範囲を校正",
"Open Settings": "設定を開く",
@@ -1199,5 +1195,34 @@
"Computed Hash": "計算されたハッシュ",
"Identifiers": "識別子",
"Read (Stream)": "読む(ストリーム)",
"Failed to start stream": "ストリームの開始に失敗しました"
"Failed to start stream": "ストリームの開始に失敗しました",
"No dictionaries enabled": "有効な辞書がありません",
"Enable a dictionary in Settings → Language → Dictionaries.": "設定 → 言語 → 辞書 で辞書を有効にしてください。",
"No definitions found": "定義が見つかりません",
"Search for {{word}} on the web.": "ウェブで {{word}} を検索。",
"Dictionary unsupported": "辞書は未対応です",
"This dictionary format is not supported yet.": "この辞書形式はまだサポートされていません。",
"Unable to load the word.": "単語を読み込めませんでした。",
"Wiktionary": "ウィクショナリー",
"Drag to reorder": "ドラッグで並べ替え",
"Built-in": "組み込み",
"Bundle is missing on this device. Re-import to use it.": "この端末にバンドルがありません。再度インポートしてご利用ください。",
"This dictionary format is not supported.": "この辞書形式はサポートされていません。",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_other": "{{count}} 件の辞書をインポートしました",
"Skipped incomplete bundles: {{names}}": "不完全なバンドルをスキップしました: {{names}}",
"Failed to import dictionary: {{message}}": "辞書のインポートに失敗しました: {{message}}",
"Dictionaries": "辞書",
"Delete Dictionary": "辞書を削除",
"Importing…": "インポート中…",
"Import Dictionary": "辞書をインポート",
"No dictionaries available.": "利用可能な辞書はありません。",
"Drag the handle on the left to reorder.": "左のハンドルをドラッグして並べ替えます。",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict バンドルには .ifo、.idx、.dict.dz ファイルが必要です(.syn は任意)。",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict バンドルは .mdx ファイルを使用します。付随する .mdd ファイルは任意です。",
"Select all the bundle files together when importing.": "インポート時はバンドルのファイルをまとめて選択してください。",
"Manage Dictionaries": "辞書を管理",
"Select Dictionary Files": "辞書ファイルを選択",
"Read on Wikipedia →": "Wikipedia で読む →"
}
@@ -781,7 +781,6 @@
"Annotate text after selection": "선택 후 텍스트 주석 달기",
"Search text after selection": "선택 후 텍스트 검색",
"Look up text in dictionary after selection": "선택 후 텍스트를 사전에서 찾기",
"Look up text in Wikipedia after selection": "선택 후 텍스트를 위키피디아에서 찾기",
"Translate text after selection": "선택 후 텍스트 번역",
"Read text aloud after selection": "선택 후 텍스트 음성 읽기",
"Proofread text after selection": "선택 후 텍스트 교정",
@@ -829,8 +828,6 @@
"Export Book": "책 내보내기",
"Whole word:": "전체 단어:",
"Error": "오류",
"Unable to load the article. Try searching directly on {{link}}.": "문서를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
"Unable to load the word. Try searching directly on {{link}}.": "단어를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
"Date Published": "출판일",
"Only for TTS:": "TTS 전용:",
"Uploaded": "업로드됨",
@@ -1106,7 +1103,6 @@
"Copy Selection": "선택 영역 복사",
"Translate Selection": "선택 영역 번역",
"Dictionary Lookup": "사전 검색",
"Wikipedia Lookup": "위키백과 검색",
"Read Aloud Selection": "선택 영역 소리 내어 읽기",
"Proofread Selection": "선택 영역 교정",
"Open Settings": "설정 열기",
@@ -1199,5 +1195,34 @@
"Computed Hash": "계산된 해시",
"Identifiers": "식별자",
"Read (Stream)": "읽기 (스트림)",
"Failed to start stream": "스트림을 시작할 수 없습니다"
"Failed to start stream": "스트림을 시작할 수 없습니다",
"No dictionaries enabled": "활성화된 사전이 없습니다",
"Enable a dictionary in Settings → Language → Dictionaries.": "설정 → 언어 → 사전 에서 사전을 활성화하세요.",
"No definitions found": "정의를 찾을 수 없습니다",
"Search for {{word}} on the web.": "웹에서 {{word}} 검색.",
"Dictionary unsupported": "지원되지 않는 사전",
"This dictionary format is not supported yet.": "이 사전 형식은 아직 지원되지 않습니다.",
"Unable to load the word.": "단어를 불러올 수 없습니다.",
"Wiktionary": "위키낱말사전",
"Drag to reorder": "드래그하여 순서 변경",
"Built-in": "내장",
"Bundle is missing on this device. Re-import to use it.": "이 기기에 번들이 없습니다. 사용하려면 다시 가져오세요.",
"This dictionary format is not supported.": "이 사전 형식은 지원되지 않습니다.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_other": "{{count}}개 사전 가져옴",
"Skipped incomplete bundles: {{names}}": "불완전한 번들 건너뜀: {{names}}",
"Failed to import dictionary: {{message}}": "사전 가져오기 실패: {{message}}",
"Dictionaries": "사전",
"Delete Dictionary": "사전 삭제",
"Importing…": "가져오는 중…",
"Import Dictionary": "사전 가져오기",
"No dictionaries available.": "사용 가능한 사전이 없습니다.",
"Drag the handle on the left to reorder.": "왼쪽 핸들을 드래그하여 순서를 변경하세요.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict 번들은 .ifo, .idx, .dict.dz 파일이 필요합니다(.syn은 선택).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict 번들은 .mdx 파일을 사용하며, 동반되는 .mdd 파일은 선택입니다.",
"Select all the bundle files together when importing.": "가져올 때 번들의 모든 파일을 함께 선택하세요.",
"Manage Dictionaries": "사전 관리",
"Select Dictionary Files": "사전 파일 선택",
"Read on Wikipedia →": "위키백과에서 읽기 →"
}
@@ -781,7 +781,6 @@
"Annotate text after selection": "Anotasi teks selepas pilihan",
"Search text after selection": "Cari teks selepas pilihan",
"Look up text in dictionary after selection": "Cari teks dalam kamus selepas pilihan",
"Look up text in Wikipedia after selection": "Cari teks dalam Wikipedia selepas pilihan",
"Translate text after selection": "Terjemah teks selepas pilihan",
"Read text aloud after selection": "Baca teks dengan kuat selepas pilihan",
"Proofread text after selection": "Semak teks selepas pilihan",
@@ -829,8 +828,6 @@
"Export Book": "Eksport Buku",
"Whole word:": "Perkataan penuh:",
"Error": "Ralat",
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuatkan artikel. Cuba cari terus di {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuatkan perkataan. Cuba cari terus di {{link}}.",
"Date Published": "Tarikh diterbitkan",
"Only for TTS:": "Hanya untuk TTS:",
"Uploaded": "Dimuat naik",
@@ -1106,7 +1103,6 @@
"Copy Selection": "Salin pilihan",
"Translate Selection": "Terjemah pilihan",
"Dictionary Lookup": "Cari dalam kamus",
"Wikipedia Lookup": "Cari di Wikipedia",
"Read Aloud Selection": "Baca kuat pilihan",
"Proofread Selection": "Semak pilihan",
"Open Settings": "Buka tetapan",
@@ -1199,5 +1195,34 @@
"Computed Hash": "Cincang Dikira",
"Identifiers": "Pengenal",
"Read (Stream)": "Baca (Strim)",
"Failed to start stream": "Gagal memulakan strim"
"Failed to start stream": "Gagal memulakan strim",
"No dictionaries enabled": "Tiada kamus diaktifkan",
"Enable a dictionary in Settings → Language → Dictionaries.": "Aktifkan kamus di Tetapan → Bahasa → Kamus.",
"No definitions found": "Tiada definisi ditemukan",
"Search for {{word}} on the web.": "Cari {{word}} di web.",
"Dictionary unsupported": "Kamus tidak disokong",
"This dictionary format is not supported yet.": "Format kamus ini belum disokong.",
"Unable to load the word.": "Tidak dapat memuatkan perkataan.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Seret untuk susun semula",
"Built-in": "Terbina dalam",
"Bundle is missing on this device. Re-import to use it.": "Bundle tiada pada peranti ini. Import semula untuk menggunakannya.",
"This dictionary format is not supported.": "Format kamus ini tidak disokong.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_other": "{{count}} kamus diimport",
"Skipped incomplete bundles: {{names}}": "Bundle tidak lengkap dilangkau: {{names}}",
"Failed to import dictionary: {{message}}": "Gagal mengimport kamus: {{message}}",
"Dictionaries": "Kamus",
"Delete Dictionary": "Padam Kamus",
"Importing…": "Mengimport…",
"Import Dictionary": "Import Kamus",
"No dictionaries available.": "Tiada kamus tersedia.",
"Drag the handle on the left to reorder.": "Seret pemegang di sebelah kiri untuk susun semula.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Bundle StarDict memerlukan fail .ifo, .idx dan .dict.dz (.syn pilihan).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Bundle MDict menggunakan fail .mdx; fail .mdd pengiring adalah pilihan.",
"Select all the bundle files together when importing.": "Pilih semua fail bundle sekali gus semasa mengimport.",
"Manage Dictionaries": "Urus Kamus",
"Select Dictionary Files": "Pilih Fail Kamus",
"Read on Wikipedia →": "Baca di Wikipedia →"
}
@@ -791,7 +791,6 @@
"Annotate text after selection": "Annoteren tekst na selectie",
"Search text after selection": "Zoek tekst na selectie",
"Look up text in dictionary after selection": "Zoek tekst op in woordenboek na selectie",
"Look up text in Wikipedia after selection": "Zoek tekst op in Wikipedia na selectie",
"Translate text after selection": "Vertaal tekst na selectie",
"Read text aloud after selection": "Lees tekst hardop na selectie",
"Proofread text after selection": "Corrigeer tekst na selectie",
@@ -839,8 +838,6 @@
"Export Book": "Boek exporteren",
"Whole word:": "Heel woord:",
"Error": "Fout",
"Unable to load the article. Try searching directly on {{link}}.": "Kan het artikel niet laden. Probeer direct te zoeken op {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Kan het woord niet laden. Probeer direct te zoeken op {{link}}.",
"Date Published": "Publicatiedatum",
"Only for TTS:": "Alleen voor TTS:",
"Uploaded": "Geüpload",
@@ -1119,7 +1116,6 @@
"Copy Selection": "Selectie kopiëren",
"Translate Selection": "Selectie vertalen",
"Dictionary Lookup": "Opzoeken in woordenboek",
"Wikipedia Lookup": "Opzoeken op Wikipedia",
"Read Aloud Selection": "Selectie voorlezen",
"Proofread Selection": "Selectie proeflezen",
"Open Settings": "Instellingen openen",
@@ -1216,5 +1212,35 @@
"Computed Hash": "Berekende hash",
"Identifiers": "Identificatoren",
"Read (Stream)": "Lezen (Stream)",
"Failed to start stream": "Kan stream niet starten"
"Failed to start stream": "Kan stream niet starten",
"No dictionaries enabled": "Geen woordenboeken ingeschakeld",
"Enable a dictionary in Settings → Language → Dictionaries.": "Schakel een woordenboek in via Instellingen → Taal → Woordenboeken.",
"No definitions found": "Geen definities gevonden",
"Search for {{word}} on the web.": "Zoek {{word}} op het web.",
"Dictionary unsupported": "Woordenboek niet ondersteund",
"This dictionary format is not supported yet.": "Dit woordenboekformaat wordt nog niet ondersteund.",
"Unable to load the word.": "Kan het woord niet laden.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Sleep om opnieuw te ordenen",
"Built-in": "Ingebouwd",
"Bundle is missing on this device. Re-import to use it.": "Bundel ontbreekt op dit apparaat. Importeer opnieuw om te gebruiken.",
"This dictionary format is not supported.": "Dit woordenboekformaat wordt niet ondersteund.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "{{count}} woordenboek geïmporteerd",
"Imported {{count}} dictionary_other": "{{count}} woordenboeken geïmporteerd",
"Skipped incomplete bundles: {{names}}": "Onvolledige bundels overgeslagen: {{names}}",
"Failed to import dictionary: {{message}}": "Importeren van woordenboek mislukt: {{message}}",
"Dictionaries": "Woordenboeken",
"Delete Dictionary": "Woordenboek verwijderen",
"Importing…": "Bezig met importeren…",
"Import Dictionary": "Woordenboek importeren",
"No dictionaries available.": "Geen woordenboeken beschikbaar.",
"Drag the handle on the left to reorder.": "Sleep de greep links om opnieuw te ordenen.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict-bundels hebben .ifo-, .idx- en .dict.dz-bestanden nodig (.syn optioneel).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict-bundels gebruiken .mdx-bestanden; bijbehorende .mdd-bestanden zijn optioneel.",
"Select all the bundle files together when importing.": "Selecteer bij het importeren alle bundelbestanden tegelijk.",
"Manage Dictionaries": "Woordenboeken beheren",
"Select Dictionary Files": "Woordenboekbestanden kiezen",
"Read on Wikipedia →": "Lees verder op Wikipedia →"
}
@@ -811,7 +811,6 @@
"Annotate text after selection": "Dodaj adnotację do zaznaczonego tekstu",
"Search text after selection": "Wyszukaj zaznaczony tekst",
"Look up text in dictionary after selection": "Sprawdź zaznaczony tekst w słowniku",
"Look up text in Wikipedia after selection": "Sprawdź zaznaczony tekst w Wikipedii",
"Translate text after selection": "Przetłumacz zaznaczony tekst",
"Read text aloud after selection": "Odczytaj zaznaczony tekst na głos",
"Proofread text after selection": "Sprawdź zaznaczony tekst",
@@ -859,8 +858,6 @@
"Export Book": "Eksportuj książkę",
"Whole word:": "Całe słowo:",
"Error": "Błąd",
"Unable to load the article. Try searching directly on {{link}}.": "Nie można załadować artykułu. Spróbuj wyszukać bezpośrednio na {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Nie można załadować słowa. Spróbuj wyszukać bezpośrednio na {{link}}.",
"Date Published": "Data wydania",
"Only for TTS:": "Tylko dla TTS:",
"Uploaded": "Przesłano",
@@ -1145,7 +1142,6 @@
"Copy Selection": "Kopiuj zaznaczenie",
"Translate Selection": "Przetłumacz zaznaczenie",
"Dictionary Lookup": "Wyszukaj w słowniku",
"Wikipedia Lookup": "Wyszukaj w Wikipedii",
"Read Aloud Selection": "Odczytaj zaznaczenie na głos",
"Proofread Selection": "Sprawdź zaznaczenie",
"Open Settings": "Otwórz ustawienia",
@@ -1250,5 +1246,37 @@
"Computed Hash": "Obliczony skrót",
"Identifiers": "Identyfikatory",
"Read (Stream)": "Czytaj (Strumień)",
"Failed to start stream": "Nie udało się uruchomić strumienia"
"Failed to start stream": "Nie udało się uruchomić strumienia",
"No dictionaries enabled": "Brak włączonych słowników",
"Enable a dictionary in Settings → Language → Dictionaries.": "Włącz słownik w Ustawienia → Język → Słowniki.",
"No definitions found": "Nie znaleziono definicji",
"Search for {{word}} on the web.": "Szukaj {{word}} w sieci.",
"Dictionary unsupported": "Słownik nieobsługiwany",
"This dictionary format is not supported yet.": "Ten format słownika nie jest jeszcze obsługiwany.",
"Unable to load the word.": "Nie można załadować słowa.",
"Wiktionary": "Wikisłownik",
"Drag to reorder": "Przeciągnij, aby zmienić kolejność",
"Built-in": "Wbudowany",
"Bundle is missing on this device. Re-import to use it.": "Brak pakietu na tym urządzeniu. Zaimportuj ponownie, aby użyć.",
"This dictionary format is not supported.": "Ten format słownika nie jest obsługiwany.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "Zaimportowano {{count}} słownik",
"Imported {{count}} dictionary_few": "Zaimportowano {{count}} słowniki",
"Imported {{count}} dictionary_many": "Zaimportowano {{count}} słowników",
"Imported {{count}} dictionary_other": "Zaimportowano {{count}} słowników",
"Skipped incomplete bundles: {{names}}": "Pominięto niekompletne pakiety: {{names}}",
"Failed to import dictionary: {{message}}": "Nie udało się zaimportować słownika: {{message}}",
"Dictionaries": "Słowniki",
"Delete Dictionary": "Usuń słownik",
"Importing…": "Importowanie…",
"Import Dictionary": "Importuj słownik",
"No dictionaries available.": "Brak dostępnych słowników.",
"Drag the handle on the left to reorder.": "Przeciągnij uchwyt po lewej, aby zmienić kolejność.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Pakiety StarDict wymagają plików .ifo, .idx i .dict.dz (.syn opcjonalny).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Pakiety MDict używają plików .mdx; towarzyszące .mdd są opcjonalne.",
"Select all the bundle files together when importing.": "Podczas importu wybierz wszystkie pliki pakietu jednocześnie.",
"Manage Dictionaries": "Zarządzaj słownikami",
"Select Dictionary Files": "Wybierz pliki słownika",
"Read on Wikipedia →": "Czytaj na Wikipedii →"
}
@@ -801,7 +801,6 @@
"Annotate text after selection": "Anotar texto após a seleção",
"Search text after selection": "Pesquisar texto após a seleção",
"Look up text in dictionary after selection": "Procurar texto no dicionário após a seleção",
"Look up text in Wikipedia after selection": "Procurar texto na Wikipedia após a seleção",
"Translate text after selection": "Traduzir texto após a seleção",
"Read text aloud after selection": "Ler texto em voz alta após a seleção",
"Proofread text after selection": "Revisar texto após a seleção",
@@ -849,8 +848,6 @@
"Export Book": "Exportar livro",
"Whole word:": "Palavra inteira:",
"Error": "Erro",
"Unable to load the article. Try searching directly on {{link}}.": "Não foi possível carregar o artigo. Tente pesquisar diretamente em {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Não foi possível carregar a palavra. Tente pesquisar diretamente em {{link}}.",
"Date Published": "Data de publicação",
"Only for TTS:": "Apenas para TTS:",
"Uploaded": "Enviado",
@@ -1132,7 +1129,6 @@
"Copy Selection": "Copiar seleção",
"Translate Selection": "Traduzir seleção",
"Dictionary Lookup": "Pesquisar no dicionário",
"Wikipedia Lookup": "Pesquisar na Wikipédia",
"Read Aloud Selection": "Ler seleção em voz alta",
"Proofread Selection": "Revisar seleção",
"Open Settings": "Abrir configurações",
@@ -1233,5 +1229,36 @@
"Computed Hash": "Hash Calculado",
"Identifiers": "Identificadores",
"Read (Stream)": "Ler (Streaming)",
"Failed to start stream": "Falha ao iniciar o streaming"
"Failed to start stream": "Falha ao iniciar o streaming",
"No dictionaries enabled": "Nenhum dicionário ativado",
"Enable a dictionary in Settings → Language → Dictionaries.": "Ative um dicionário em Configurações → Idioma → Dicionários.",
"No definitions found": "Nenhuma definição encontrada",
"Search for {{word}} on the web.": "Pesquisar {{word}} na web.",
"Dictionary unsupported": "Dicionário não suportado",
"This dictionary format is not supported yet.": "Este formato de dicionário ainda não é suportado.",
"Unable to load the word.": "Não foi possível carregar a palavra.",
"Wiktionary": "Wikcionário",
"Drag to reorder": "Arraste para reordenar",
"Built-in": "Integrado",
"Bundle is missing on this device. Re-import to use it.": "O pacote está ausente neste dispositivo. Reimporte para usar.",
"This dictionary format is not supported.": "Este formato de dicionário não é suportado.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "{{count}} dicionário importado",
"Imported {{count}} dictionary_many": "{{count}} dicionários importados",
"Imported {{count}} dictionary_other": "{{count}} dicionários importados",
"Skipped incomplete bundles: {{names}}": "Pacotes incompletos ignorados: {{names}}",
"Failed to import dictionary: {{message}}": "Falha ao importar dicionário: {{message}}",
"Dictionaries": "Dicionários",
"Delete Dictionary": "Excluir dicionário",
"Importing…": "Importando…",
"Import Dictionary": "Importar dicionário",
"No dictionaries available.": "Nenhum dicionário disponível.",
"Drag the handle on the left to reorder.": "Arraste o punho à esquerda para reordenar.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Pacotes StarDict precisam dos arquivos .ifo, .idx e .dict.dz (.syn opcional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Pacotes MDict usam arquivos .mdx; arquivos .mdd companheiros são opcionais.",
"Select all the bundle files together when importing.": "Selecione todos os arquivos do pacote juntos ao importar.",
"Manage Dictionaries": "Gerenciar dicionários",
"Select Dictionary Files": "Selecionar arquivos do dicionário",
"Read on Wikipedia →": "Leia na Wikipédia →"
}
@@ -308,7 +308,6 @@
"Dictionary": "Dicţionar",
"Look up text in dictionary after selection": "Căutați text în dicționar după selecție",
"Wikipedia": "Wikipedia",
"Look up text in Wikipedia after selection": "Căutați text în Wikipedia după selecție",
"Translate": "Traduce",
"Translate text after selection": "Traduceți textul după selecție",
"Speak": "Vorbește",
@@ -423,8 +422,6 @@
"No translation available.": "Nicio traducere disponibilă.",
"Translated by {{provider}}.": "Tradus de {{provider}}.",
"Error": "Eroare",
"Unable to load the article. Try searching directly on {{link}}.": "Nu se poate încărca articolul. Încercați să căutați direct pe {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Nu se poate încărca cuvântul. Încercați să căutați direct pe {{link}}.",
"Remove Bookmark": "Eliminați marcajul",
"Add Bookmark": "Adăugați marcaj",
"Books Content": "Conținutul cărților",
@@ -1079,7 +1076,6 @@
"Copy Selection": "Copiază selecția",
"Translate Selection": "Traduce selecția",
"Dictionary Lookup": "Caută în dicționar",
"Wikipedia Lookup": "Caută pe Wikipedia",
"Read Aloud Selection": "Citește selecția cu voce tare",
"Proofread Selection": "Corectează selecția",
"Open Settings": "Deschide setările",
@@ -1233,5 +1229,36 @@
"Computed Hash": "Hash calculat",
"Identifiers": "Identificatori",
"Read (Stream)": "Citește (Flux)",
"Failed to start stream": "Eroare la pornirea fluxului"
"Failed to start stream": "Eroare la pornirea fluxului",
"No dictionaries enabled": "Niciun dicționar activat",
"Enable a dictionary in Settings → Language → Dictionaries.": "Activează un dicționar din Setări → Limbă → Dicționare.",
"No definitions found": "Nu s-au găsit definiții",
"Search for {{word}} on the web.": "Caută {{word}} pe web.",
"Dictionary unsupported": "Dicționarul nu este acceptat",
"This dictionary format is not supported yet.": "Acest format de dicționar nu este încă acceptat.",
"Unable to load the word.": "Nu s-a putut încărca cuvântul.",
"Wiktionary": "Wikționar",
"Drag to reorder": "Trage pentru a reordona",
"Built-in": "Încorporat",
"Bundle is missing on this device. Re-import to use it.": "Pachetul lipsește de pe acest dispozitiv. Reimportă pentru a-l folosi.",
"This dictionary format is not supported.": "Acest format de dicționar nu este acceptat.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "A fost importat {{count}} dicționar",
"Imported {{count}} dictionary_few": "Au fost importate {{count}} dicționare",
"Imported {{count}} dictionary_other": "Au fost importate {{count}} dicționare",
"Skipped incomplete bundles: {{names}}": "Pachete incomplete ignorate: {{names}}",
"Failed to import dictionary: {{message}}": "Importul dicționarului a eșuat: {{message}}",
"Dictionaries": "Dicționare",
"Delete Dictionary": "Șterge dicționarul",
"Importing…": "Se importă…",
"Import Dictionary": "Importă dicționar",
"No dictionaries available.": "Niciun dicționar disponibil.",
"Drag the handle on the left to reorder.": "Trage de mâner din stânga pentru a reordona.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Pachetele StarDict au nevoie de fișierele .ifo, .idx și .dict.dz (.syn opțional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Pachetele MDict folosesc fișiere .mdx; fișierele .mdd însoțitoare sunt opționale.",
"Select all the bundle files together when importing.": "Selectează împreună toate fișierele pachetului la import.",
"Manage Dictionaries": "Gestionează dicționarele",
"Select Dictionary Files": "Selectează fișierele dicționarului",
"Read on Wikipedia →": "Citește pe Wikipedia →"
}
@@ -811,7 +811,6 @@
"Annotate text after selection": "Аннотировать текст после выделения",
"Search text after selection": "Искать текст после выделения",
"Look up text in dictionary after selection": "Искать текст в словаре после выделения",
"Look up text in Wikipedia after selection": "Искать текст в Википедии после выделения",
"Translate text after selection": "Перевести текст после выделения",
"Read text aloud after selection": "Прочитать текст вслух после выделения",
"Proofread text after selection": "Корректировать текст после выделения",
@@ -859,8 +858,6 @@
"Export Book": "Экспорт книги",
"Whole word:": "Слово целиком:",
"Error": "Ошибка",
"Unable to load the article. Try searching directly on {{link}}.": "Не удалось загрузить статью. Попробуйте искать напрямую на {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Не удалось загрузить слово. Попробуйте искать напрямую на {{link}}.",
"Date Published": "Дата публикации",
"Only for TTS:": "Только для TTS:",
"Uploaded": "Загружено",
@@ -1145,7 +1142,6 @@
"Copy Selection": "Копировать выбранное",
"Translate Selection": "Перевести выбранное",
"Dictionary Lookup": "Поиск в словаре",
"Wikipedia Lookup": "Поиск в Википедии",
"Read Aloud Selection": "Прочитать выбранное вслух",
"Proofread Selection": "Проверить выбранное",
"Open Settings": "Открыть настройки",
@@ -1250,5 +1246,37 @@
"Computed Hash": "Вычисленный хеш",
"Identifiers": "Идентификаторы",
"Read (Stream)": "Читать (Поток)",
"Failed to start stream": "Не удалось запустить поток"
"Failed to start stream": "Не удалось запустить поток",
"No dictionaries enabled": "Нет включённых словарей",
"Enable a dictionary in Settings → Language → Dictionaries.": "Включите словарь в Настройки → Язык → Словари.",
"No definitions found": "Определения не найдены",
"Search for {{word}} on the web.": "Искать {{word}} в интернете.",
"Dictionary unsupported": "Словарь не поддерживается",
"This dictionary format is not supported yet.": "Этот формат словаря пока не поддерживается.",
"Unable to load the word.": "Не удалось загрузить слово.",
"Wiktionary": "Викисловарь",
"Drag to reorder": "Перетащите для изменения порядка",
"Built-in": "Встроенный",
"Bundle is missing on this device. Re-import to use it.": "Пакет отсутствует на этом устройстве. Импортируйте повторно для использования.",
"This dictionary format is not supported.": "Этот формат словаря не поддерживается.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "Импортирован {{count}} словарь",
"Imported {{count}} dictionary_few": "Импортировано {{count}} словаря",
"Imported {{count}} dictionary_many": "Импортировано {{count}} словарей",
"Imported {{count}} dictionary_other": "Импортировано {{count}} словарей",
"Skipped incomplete bundles: {{names}}": "Пропущены неполные пакеты: {{names}}",
"Failed to import dictionary: {{message}}": "Не удалось импортировать словарь: {{message}}",
"Dictionaries": "Словари",
"Delete Dictionary": "Удалить словарь",
"Importing…": "Импорт…",
"Import Dictionary": "Импорт словаря",
"No dictionaries available.": "Нет доступных словарей.",
"Drag the handle on the left to reorder.": "Перетащите ручку слева для изменения порядка.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Пакеты StarDict требуют файлов .ifo, .idx и .dict.dz (.syn — опционально).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Пакеты MDict используют файлы .mdx; сопутствующие .mdd — опционально.",
"Select all the bundle files together when importing.": "При импорте выбирайте все файлы пакета вместе.",
"Manage Dictionaries": "Управление словарями",
"Select Dictionary Files": "Выберите файлы словаря",
"Read on Wikipedia →": "Читать в Википедии →"
}
@@ -791,7 +791,6 @@
"Annotate text after selection": "තේරීමෙන් පසු පෙළ සටහන් කරන්න",
"Search text after selection": "තේරීමෙන් පසු පෙළ සෙවීම",
"Look up text in dictionary after selection": "තේරීමෙන් පසු ශබ්දකෝෂයේ පෙළ සෙවීම",
"Look up text in Wikipedia after selection": "තේරීමෙන් පසු විකිපීඩියාවේ පෙළ සෙවීම",
"Translate text after selection": "තේරීමෙන් පසු පෙළ පරිවර්තනය කරන්න",
"Read text aloud after selection": "තේරීමෙන් පසු පෙළ උච්චාරණය කරන්න",
"Proofread text after selection": "තේරීමෙන් පසු පෙළ සංශෝධනය කරන්න",
@@ -839,8 +838,6 @@
"Export Book": "පොත අපනයනය කරන්න",
"Whole word:": "සම්පූර්ණ වචනය:",
"Error": "දෝෂය",
"Unable to load the article. Try searching directly on {{link}}.": "ලිපිය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
"Unable to load the word. Try searching directly on {{link}}.": "වචනය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
"Date Published": "ප්‍රකාශන දිනය",
"Only for TTS:": "TTS සඳහා පමණක්:",
"Uploaded": "උඩුගත කරන ලදී",
@@ -1119,7 +1116,6 @@
"Copy Selection": "තේරීම පිටපත් කරන්න",
"Translate Selection": "තේරීම පරිවර්තනය කරන්න",
"Dictionary Lookup": "ශබ්දකෝෂයේ සොයන්න",
"Wikipedia Lookup": "විකිපීඩියාවේ සොයන්න",
"Read Aloud Selection": "තේරීම ශබ්ද නගා කියවන්න",
"Proofread Selection": "තේරීම සෝදුපත් කරන්න",
"Open Settings": "සැකසුම් විවෘත කරන්න",
@@ -1216,5 +1212,35 @@
"Computed Hash": "ගණනය කළ හෑෂ්",
"Identifiers": "හඳුනාගැනීම්",
"Read (Stream)": "කියවන්න (ස්ට්‍රීම්)",
"Failed to start stream": "ස්ට්‍රීම් ආරම්භ කිරීම අසාර්ථකයි"
"Failed to start stream": "ස්ට්‍රීම් ආරම්භ කිරීම අසාර්ථකයි",
"No dictionaries enabled": "ශබ්දකෝෂ සක්‍රීය කර නැත",
"Enable a dictionary in Settings → Language → Dictionaries.": "සැකසීම් → භාෂාව → ශබ්දකෝෂ වෙත ගොස් ශබ්දකෝෂයක් සක්‍රීය කරන්න.",
"No definitions found": "අර්ථදැක්වීම් හමු නොවීය",
"Search for {{word}} on the web.": "වෙබයේ {{word}} සොයන්න.",
"Dictionary unsupported": "ශබ්දකෝෂයට සහාය නොදක්වයි",
"This dictionary format is not supported yet.": "මෙම ශබ්දකෝෂ ආකෘතියට තවම සහාය නොදක්වයි.",
"Unable to load the word.": "වචනය පූරණය කළ නොහැකි විය.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "නැවත සකස් කිරීමට අදින්න",
"Built-in": "සාමාන්‍ය",
"Bundle is missing on this device. Re-import to use it.": "මෙම උපාංගයේ බණ්ඩලය නැත. භාවිතයට නැවත ආයාත කරන්න.",
"This dictionary format is not supported.": "මෙම ශබ්දකෝෂ ආකෘතියට සහාය නොදක්වයි.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "ශබ්දකෝෂ {{count}}ක් ආයාත කළා",
"Imported {{count}} dictionary_other": "ශබ්දකෝෂ {{count}}ක් ආයාත කළා",
"Skipped incomplete bundles: {{names}}": "අසම්පූර්ණ බණ්ඩල මඟ හරින ලදී: {{names}}",
"Failed to import dictionary: {{message}}": "ශබ්දකෝෂය ආයාත කිරීමට අසමත් විය: {{message}}",
"Dictionaries": "ශබ්දකෝෂ",
"Delete Dictionary": "ශබ්දකෝෂය මකන්න",
"Importing…": "ආයාත වෙමින්…",
"Import Dictionary": "ශබ්දකෝෂයක් ආයාත කරන්න",
"No dictionaries available.": "ලබා ගත හැකි ශබ්දකෝෂ නැත.",
"Drag the handle on the left to reorder.": "නැවත සකස් කිරීමට වම් පස ඇති පතන අදින්න.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict බණ්ඩලවලට .ifo, .idx සහ .dict.dz ගොනු අවශ්‍යය (.syn විකල්ප).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict බණ්ඩල .mdx ගොනු භාවිතා කරයි; සහායක .mdd ගොනු විකල්ප වේ.",
"Select all the bundle files together when importing.": "ආයාත කරන විට බණ්ඩලේ සියලු ගොනු එකවර තෝරන්න.",
"Manage Dictionaries": "ශබ්දකෝෂ කළමනාකරණය",
"Select Dictionary Files": "ශබ්දකෝෂ ගොනු තෝරන්න",
"Read on Wikipedia →": "Wikipedia හි කියවන්න →"
}
@@ -297,7 +297,6 @@
"Dictionary": "Slovar",
"Look up text in dictionary after selection": "Poišči besedilo v slovarju po izbiri",
"Wikipedia": "Wikipedia",
"Look up text in Wikipedia after selection": "Poišči besedilo v Wikipediji po izbiri",
"Translate": "Prevedi",
"Translate text after selection": "Prevedi besedilo po izbiri",
"Speak": "Govori",
@@ -400,8 +399,6 @@
"No translation available.": "Prevod ni na voljo.",
"Translated by {{provider}}.": "Prevedeno s {{provider}}.",
"Error": "Napaka",
"Unable to load the article. Try searching directly on {{link}}.": "Članka ni mogoče naložiti. Poskusite iskati neposredno na {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Besede ni mogoče naložiti. Poskusite iskati neposredno na {{link}}.",
"Remove Bookmark": "Odstrani zaznamek",
"Add Bookmark": "Dodaj zaznamek",
"Books Content": "Vsebina knjig",
@@ -1145,7 +1142,6 @@
"Copy Selection": "Kopiraj izbiro",
"Translate Selection": "Prevedi izbiro",
"Dictionary Lookup": "Iskanje v slovarju",
"Wikipedia Lookup": "Iskanje na Wikipediji",
"Read Aloud Selection": "Preberi izbiro na glas",
"Proofread Selection": "Lektoriraj izbiro",
"Open Settings": "Odpri nastavitve",
@@ -1250,5 +1246,37 @@
"Computed Hash": "Izračunani hash",
"Identifiers": "Identifikatorji",
"Read (Stream)": "Beri (pretok)",
"Failed to start stream": "Pretoka ni bilo mogoče zagnati"
"Failed to start stream": "Pretoka ni bilo mogoče zagnati",
"No dictionaries enabled": "Ni omogočenih slovarjev",
"Enable a dictionary in Settings → Language → Dictionaries.": "Omogoči slovar v Nastavitve → Jezik → Slovarji.",
"No definitions found": "Ni najdenih razlag",
"Search for {{word}} on the web.": "Poišči {{word}} v spletu.",
"Dictionary unsupported": "Slovar ni podprt",
"This dictionary format is not supported yet.": "Ta oblika slovarja še ni podprta.",
"Unable to load the word.": "Besede ni mogoče naložiti.",
"Wiktionary": "Wikislovar",
"Drag to reorder": "Povleci za prerazporeditev",
"Built-in": "Vgrajeno",
"Bundle is missing on this device. Re-import to use it.": "Sveženj manjka na tej napravi. Ponovno uvozi za uporabo.",
"This dictionary format is not supported.": "Ta oblika slovarja ni podprta.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "Uvožen {{count}} slovar",
"Imported {{count}} dictionary_two": "Uvožena {{count}} slovarja",
"Imported {{count}} dictionary_few": "Uvoženi {{count}} slovarji",
"Imported {{count}} dictionary_other": "Uvoženih {{count}} slovarjev",
"Skipped incomplete bundles: {{names}}": "Preskočeni nepopolni svežnji: {{names}}",
"Failed to import dictionary: {{message}}": "Uvoz slovarja ni uspel: {{message}}",
"Dictionaries": "Slovarji",
"Delete Dictionary": "Izbriši slovar",
"Importing…": "Uvažanje …",
"Import Dictionary": "Uvozi slovar",
"No dictionaries available.": "Ni razpoložljivih slovarjev.",
"Drag the handle on the left to reorder.": "Povleci ročaj levo za prerazporeditev.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Svežnji StarDict potrebujejo datoteke .ifo, .idx in .dict.dz (.syn neobvezno).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Svežnji MDict uporabljajo datoteke .mdx; spremljajoče .mdd so neobvezne.",
"Select all the bundle files together when importing.": "Pri uvozu izberi vse datoteke svežnja skupaj.",
"Manage Dictionaries": "Upravljanje slovarjev",
"Select Dictionary Files": "Izberi datoteke slovarja",
"Read on Wikipedia →": "Beri v Wikipediji →"
}
@@ -791,7 +791,6 @@
"Annotate text after selection": "Anteckna text efter markering",
"Search text after selection": "Sök text efter markering",
"Look up text in dictionary after selection": "Slå upp text i ordbok efter markering",
"Look up text in Wikipedia after selection": "Slå upp text i Wikipedia efter markering",
"Translate text after selection": "Översätt text efter markering",
"Read text aloud after selection": "Läs upp text efter markering",
"Proofread text after selection": "Korrekturläs text efter markering",
@@ -839,8 +838,6 @@
"Export Book": "Exportera bok",
"Whole word:": "Hela ordet:",
"Error": "Fel",
"Unable to load the article. Try searching directly on {{link}}.": "Kan inte ladda artikeln. Försök söka direkt på {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Kan inte ladda ordet. Försök söka direkt på {{link}}.",
"Date Published": "Publiceringsdatum",
"Only for TTS:": "Endast för TTS:",
"Uploaded": "Uppladdad",
@@ -1119,7 +1116,6 @@
"Copy Selection": "Kopiera urval",
"Translate Selection": "Översätt urval",
"Dictionary Lookup": "Slå upp i ordbok",
"Wikipedia Lookup": "Slå upp på Wikipedia",
"Read Aloud Selection": "Läs upp urval",
"Proofread Selection": "Korrekturläs urval",
"Open Settings": "Öppna inställningar",
@@ -1216,5 +1212,35 @@
"Computed Hash": "Beräknat hash",
"Identifiers": "Identifierare",
"Read (Stream)": "Läs (ström)",
"Failed to start stream": "Det gick inte att starta strömmen"
"Failed to start stream": "Det gick inte att starta strömmen",
"No dictionaries enabled": "Inga ordböcker aktiverade",
"Enable a dictionary in Settings → Language → Dictionaries.": "Aktivera en ordbok i Inställningar → Språk → Ordböcker.",
"No definitions found": "Inga definitioner hittades",
"Search for {{word}} on the web.": "Sök efter {{word}} på webben.",
"Dictionary unsupported": "Ordbok stöds inte",
"This dictionary format is not supported yet.": "Det här ordboksformatet stöds inte än.",
"Unable to load the word.": "Det gick inte att läsa in ordet.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Dra för att ändra ordning",
"Built-in": "Inbyggt",
"Bundle is missing on this device. Re-import to use it.": "Paketet saknas på den här enheten. Importera igen för att använda.",
"This dictionary format is not supported.": "Det här ordboksformatet stöds inte.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "{{count}} ordbok importerad",
"Imported {{count}} dictionary_other": "{{count}} ordböcker importerade",
"Skipped incomplete bundles: {{names}}": "Ofullständiga paket hoppades över: {{names}}",
"Failed to import dictionary: {{message}}": "Misslyckades att importera ordbok: {{message}}",
"Dictionaries": "Ordböcker",
"Delete Dictionary": "Ta bort ordbok",
"Importing…": "Importerar…",
"Import Dictionary": "Importera ordbok",
"No dictionaries available.": "Inga ordböcker tillgängliga.",
"Drag the handle on the left to reorder.": "Dra i handtaget till vänster för att ändra ordning.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict-paket kräver .ifo-, .idx- och .dict.dz-filer (.syn valfri).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict-paket använder .mdx-filer; tillhörande .mdd-filer är valfria.",
"Select all the bundle files together when importing.": "Markera alla paketfiler tillsammans när du importerar.",
"Manage Dictionaries": "Hantera ordböcker",
"Select Dictionary Files": "Välj ordboksfiler",
"Read on Wikipedia →": "Läs på Wikipedia →"
}
@@ -791,7 +791,6 @@
"Annotate text after selection": "உரையை தேர்வுக்குப் பிறகு கருத்துரை இடு",
"Search text after selection": "உரையை தேர்வுக்குப் பிறகு தேடு",
"Look up text in dictionary after selection": "தேர்வுக்குப் பிறகு அகராதியில் உரையைத் தேடு",
"Look up text in Wikipedia after selection": "தேர்வுக்குப் பிறகு விக்கிப்பீடியாவில் உரையைத் தேடு",
"Translate text after selection": "தேர்வுக்குப் பிறகு உரையை மொழிபெயர்",
"Read text aloud after selection": "தேர்வுக்குப் பிறகு உரையை ஓதுக",
"Proofread text after selection": "தேர்வுக்குப் பிறகு உரையை திருத்துக",
@@ -839,8 +838,6 @@
"Export Book": "புத்தகத்தை ஏற்றுமதி செய்",
"Whole word:": "முழு சொல்:",
"Error": "பிழை",
"Unable to load the article. Try searching directly on {{link}}.": "கட்டுரையை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
"Unable to load the word. Try searching directly on {{link}}.": "சொல்லை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
"Date Published": "வெளியீட்டு தேதி",
"Only for TTS:": "TTS க்கு மட்டும்:",
"Uploaded": "பதிவேற்றப்பட்டது",
@@ -1119,7 +1116,6 @@
"Copy Selection": "தேர்வை நகலெடுக்கவும்",
"Translate Selection": "தேர்வை மொழிபெயர்க்கவும்",
"Dictionary Lookup": "அகராதியில் தேடுக",
"Wikipedia Lookup": "விக்கிப்பீடியாவில் தேடுக",
"Read Aloud Selection": "தேர்வை சத்தமாக படிக்கவும்",
"Proofread Selection": "தேர்வை சரிபார்க்கவும்",
"Open Settings": "அமைப்புகளைத் திறக்கவும்",
@@ -1216,5 +1212,35 @@
"Computed Hash": "கணக்கிடப்பட்ட ஹாஷ்",
"Identifiers": "அடையாளங்காட்டிகள்",
"Read (Stream)": "படிக்க (ஸ்ட்ரீம்)",
"Failed to start stream": "ஸ்ட்ரீமைத் தொடங்க முடியவில்லை"
"Failed to start stream": "ஸ்ட்ரீமைத் தொடங்க முடியவில்லை",
"No dictionaries enabled": "எந்த அகராதியும் இயக்கப்படவில்லை",
"Enable a dictionary in Settings → Language → Dictionaries.": "அமைப்புகள் → மொழி → அகராதிகள் என்பதில் ஒரு அகராதியை இயக்கவும்.",
"No definitions found": "வரையறைகள் எதுவும் கிடைக்கவில்லை",
"Search for {{word}} on the web.": "வலையில் {{word}} ஐத் தேடவும்.",
"Dictionary unsupported": "அகராதி ஆதரிக்கப்படவில்லை",
"This dictionary format is not supported yet.": "இந்த அகராதி வடிவம் இன்னும் ஆதரிக்கப்படவில்லை.",
"Unable to load the word.": "சொல்லை ஏற்ற முடியவில்லை.",
"Wiktionary": "விக்சனரி",
"Drag to reorder": "மறு வரிசைப்படுத்த இழுக்கவும்",
"Built-in": "உள்ளமைந்த",
"Bundle is missing on this device. Re-import to use it.": "இந்தச் சாதனத்தில் தொகுப்பு இல்லை. பயன்படுத்த மீண்டும் இறக்குமதி செய்யவும்.",
"This dictionary format is not supported.": "இந்த அகராதி வடிவம் ஆதரிக்கப்படவில்லை.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "{{count}} அகராதி இறக்குமதி செய்யப்பட்டது",
"Imported {{count}} dictionary_other": "{{count}} அகராதிகள் இறக்குமதி செய்யப்பட்டன",
"Skipped incomplete bundles: {{names}}": "முழுமையற்ற தொகுப்புகள் தவிர்க்கப்பட்டன: {{names}}",
"Failed to import dictionary: {{message}}": "அகராதியை இறக்குமதி செய்வதில் தோல்வி: {{message}}",
"Dictionaries": "அகராதிகள்",
"Delete Dictionary": "அகராதியை நீக்கு",
"Importing…": "இறக்குமதி செய்கிறது…",
"Import Dictionary": "அகராதியை இறக்குமதி செய்",
"No dictionaries available.": "அகராதிகள் கிடைக்கவில்லை.",
"Drag the handle on the left to reorder.": "மறு வரிசைப்படுத்த இடதுபுறம் உள்ள கைப்பிடியை இழுக்கவும்.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict தொகுப்புகளுக்கு .ifo, .idx, .dict.dz கோப்புகள் தேவை (.syn விருப்பம்).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict தொகுப்புகள் .mdx கோப்புகளைப் பயன்படுத்தும்; தொடர்புடைய .mdd கோப்புகள் விருப்பம்.",
"Select all the bundle files together when importing.": "இறக்குமதி செய்யும்போது தொகுப்பின் அனைத்துக் கோப்புகளையும் ஒரே நேரத்தில் தேர்ந்தெடுக்கவும்.",
"Manage Dictionaries": "அகராதிகளை நிர்வகி",
"Select Dictionary Files": "அகராதிக் கோப்புகளைத் தேர்ந்தெடு",
"Read on Wikipedia →": "விக்கிப்பீடியாவில் படிக்க →"
}
@@ -781,7 +781,6 @@
"Annotate text after selection": "เพิ่มหมายเหตุข้อความหลังการเลือก",
"Search text after selection": "ค้นหาข้อความหลังการเลือก",
"Look up text in dictionary after selection": "ค้นหาข้อความในพจนานุกรมหลังการเลือก",
"Look up text in Wikipedia after selection": "ค้นหาข้อความในวิกิพีเดียหลังการเลือก",
"Translate text after selection": "แปลข้อความหลังการเลือก",
"Read text aloud after selection": "อ่านข้อความออกเสียงหลังการเลือก",
"Proofread text after selection": "ตรวจทานข้อความหลังการเลือก",
@@ -829,8 +828,6 @@
"Export Book": "ส่งออกหนังสือ",
"Whole word:": "คำทั้งคำ:",
"Error": "ข้อผิดพลาด",
"Unable to load the article. Try searching directly on {{link}}.": "ไม่สามารถโหลดบทความได้ ลองค้นหาโดยตรงที่ {{link}}",
"Unable to load the word. Try searching directly on {{link}}.": "ไม่สามารถโหลดคำได้ ลองค้นหาโดยตรงที่ {{link}}",
"Date Published": "วันที่เผยแพร่",
"Only for TTS:": "สำหรับ TTS เท่านั้น:",
"Uploaded": "อัปโหลดแล้ว",
@@ -1106,7 +1103,6 @@
"Copy Selection": "คัดลอกข้อความที่เลือก",
"Translate Selection": "แปลข้อความที่เลือก",
"Dictionary Lookup": "ค้นหาในพจนานุกรม",
"Wikipedia Lookup": "ค้นหาใน Wikipedia",
"Read Aloud Selection": "อ่านออกเสียงข้อความที่เลือก",
"Proofread Selection": "ตรวจทานข้อความที่เลือก",
"Open Settings": "เปิดการตั้งค่า",
@@ -1199,5 +1195,34 @@
"Computed Hash": "แฮชที่คำนวณ",
"Identifiers": "ตัวระบุ",
"Read (Stream)": "อ่าน (สตรีม)",
"Failed to start stream": "ไม่สามารถเริ่มสตรีมได้"
"Failed to start stream": "ไม่สามารถเริ่มสตรีมได้",
"No dictionaries enabled": "ไม่มีพจนานุกรมที่เปิดใช้งาน",
"Enable a dictionary in Settings → Language → Dictionaries.": "เปิดใช้พจนานุกรมที่ ตั้งค่า → ภาษา → พจนานุกรม",
"No definitions found": "ไม่พบคำจำกัดความ",
"Search for {{word}} on the web.": "ค้นหา {{word}} บนเว็บ",
"Dictionary unsupported": "ไม่รองรับพจนานุกรมนี้",
"This dictionary format is not supported yet.": "ยังไม่รองรับรูปแบบพจนานุกรมนี้",
"Unable to load the word.": "ไม่สามารถโหลดคำได้",
"Wiktionary": "วิกิพจนานุกรม",
"Drag to reorder": "ลากเพื่อจัดลำดับใหม่",
"Built-in": "ในตัว",
"Bundle is missing on this device. Re-import to use it.": "ไม่พบบันเดิลบนอุปกรณ์นี้ นำเข้าใหม่เพื่อใช้งาน",
"This dictionary format is not supported.": "ไม่รองรับรูปแบบพจนานุกรมนี้",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_other": "นำเข้าพจนานุกรม {{count}} เล่ม",
"Skipped incomplete bundles: {{names}}": "ข้ามบันเดิลที่ไม่สมบูรณ์: {{names}}",
"Failed to import dictionary: {{message}}": "นำเข้าพจนานุกรมไม่สำเร็จ: {{message}}",
"Dictionaries": "พจนานุกรม",
"Delete Dictionary": "ลบพจนานุกรม",
"Importing…": "กำลังนำเข้า…",
"Import Dictionary": "นำเข้าพจนานุกรม",
"No dictionaries available.": "ไม่มีพจนานุกรม",
"Drag the handle on the left to reorder.": "ลากที่จับทางซ้ายเพื่อจัดลำดับใหม่",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "บันเดิล StarDict ต้องมีไฟล์ .ifo, .idx และ .dict.dz (.syn ไม่บังคับ)",
"MDict bundles use .mdx files; companion .mdd files are optional.": "บันเดิล MDict ใช้ไฟล์ .mdx ส่วนไฟล์ .mdd ที่มาด้วยเป็นทางเลือก",
"Select all the bundle files together when importing.": "เลือกไฟล์ทั้งหมดของบันเดิลพร้อมกันเมื่อทำการนำเข้า",
"Manage Dictionaries": "จัดการพจนานุกรม",
"Select Dictionary Files": "เลือกไฟล์พจนานุกรม",
"Read on Wikipedia →": "อ่านบนวิกิพีเดีย →"
}
@@ -791,7 +791,6 @@
"Annotate text after selection": "Seçimden sonra metni not al",
"Search text after selection": "Seçimden sonra metni ara",
"Look up text in dictionary after selection": "Seçimden sonra metni sözlükte ara",
"Look up text in Wikipedia after selection": "Seçimden sonra metni Vikipedya'da ara",
"Translate text after selection": "Seçimden sonra metni çevir",
"Read text aloud after selection": "Seçimden sonra metni sesli oku",
"Proofread text after selection": "Seçimden sonra metni düzelt",
@@ -839,8 +838,6 @@
"Export Book": "Kitabı Dışa Aktar",
"Whole word:": "Tam kelime:",
"Error": "Hata",
"Unable to load the article. Try searching directly on {{link}}.": "Makale yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
"Unable to load the word. Try searching directly on {{link}}.": "Kelime yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
"Date Published": "Yayın tarihi",
"Only for TTS:": "Sadece TTS için:",
"Uploaded": "Yüklendi",
@@ -1119,7 +1116,6 @@
"Copy Selection": "Seçimi kopyala",
"Translate Selection": "Seçimi çevir",
"Dictionary Lookup": "Sözlükte ara",
"Wikipedia Lookup": "Wikipedia'da ara",
"Read Aloud Selection": "Seçimi sesli oku",
"Proofread Selection": "Seçimi düzelt",
"Open Settings": "Ayarları aç",
@@ -1216,5 +1212,35 @@
"Computed Hash": "Hesaplanan Hash",
"Identifiers": "Tanımlayıcılar",
"Read (Stream)": "Oku (Akış)",
"Failed to start stream": "Akış başlatılamadı"
"Failed to start stream": "Akış başlatılamadı",
"No dictionaries enabled": "Etkin sözlük yok",
"Enable a dictionary in Settings → Language → Dictionaries.": "Ayarlar → Dil → Sözlükler kısmından bir sözlük etkinleştirin.",
"No definitions found": "Tanım bulunamadı",
"Search for {{word}} on the web.": "Webde {{word}} ara.",
"Dictionary unsupported": "Sözlük desteklenmiyor",
"This dictionary format is not supported yet.": "Bu sözlük biçimi henüz desteklenmiyor.",
"Unable to load the word.": "Sözcük yüklenemedi.",
"Wiktionary": "Vikisözlük",
"Drag to reorder": "Yeniden sıralamak için sürükleyin",
"Built-in": "Yerleşik",
"Bundle is missing on this device. Re-import to use it.": "Paket bu cihazda yok. Kullanmak için yeniden içe aktarın.",
"This dictionary format is not supported.": "Bu sözlük biçimi desteklenmiyor.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "{{count}} sözlük içe aktarıldı",
"Imported {{count}} dictionary_other": "{{count}} sözlük içe aktarıldı",
"Skipped incomplete bundles: {{names}}": "Eksik paketler atlandı: {{names}}",
"Failed to import dictionary: {{message}}": "Sözlük içe aktarılamadı: {{message}}",
"Dictionaries": "Sözlükler",
"Delete Dictionary": "Sözlüğü sil",
"Importing…": "İçe aktarılıyor…",
"Import Dictionary": "Sözlük içe aktar",
"No dictionaries available.": "Kullanılabilir sözlük yok.",
"Drag the handle on the left to reorder.": "Yeniden sıralamak için soldaki tutamacı sürükleyin.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict paketleri .ifo, .idx ve .dict.dz dosyalarını gerektirir (.syn isteğe bağlı).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict paketleri .mdx dosyalarını kullanır; eşlik eden .mdd dosyaları isteğe bağlıdır.",
"Select all the bundle files together when importing.": "İçe aktarırken paketin tüm dosyalarını birlikte seçin.",
"Manage Dictionaries": "Sözlükleri yönet",
"Select Dictionary Files": "Sözlük dosyalarını seç",
"Read on Wikipedia →": "Wikipedia'da oku →"
}
@@ -811,7 +811,6 @@
"Annotate text after selection": "Додати анотацію до тексту після виділення",
"Search text after selection": "Шукати текст після виділення",
"Look up text in dictionary after selection": "Шукати текст у словнику після виділення",
"Look up text in Wikipedia after selection": "Шукати текст у Вікіпедії після виділення",
"Translate text after selection": "Перекласти текст після виділення",
"Read text aloud after selection": "Прочитати текст вголос після виділення",
"Proofread text after selection": "Вичитати текст після виділення",
@@ -859,8 +858,6 @@
"Export Book": "Експортувати книгу",
"Whole word:": "Ціле слово:",
"Error": "Помилка",
"Unable to load the article. Try searching directly on {{link}}.": "Не вдалося завантажити статтю. Спробуйте шукати безпосередньо на {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Не вдалося завантажити слово. Спробуйте шукати безпосередньо на {{link}}.",
"Date Published": "Дата публікації",
"Only for TTS:": "Тільки для TTS:",
"Uploaded": "Завантажено",
@@ -1145,7 +1142,6 @@
"Copy Selection": "Копіювати вибране",
"Translate Selection": "Перекласти вибране",
"Dictionary Lookup": "Пошук у словнику",
"Wikipedia Lookup": "Пошук у Вікіпедії",
"Read Aloud Selection": "Прочитати вибране вголос",
"Proofread Selection": "Перевірити вибране",
"Open Settings": "Відкрити налаштування",
@@ -1250,5 +1246,37 @@
"Computed Hash": "Обчислений хеш",
"Identifiers": "Ідентифікатори",
"Read (Stream)": "Читати (Потік)",
"Failed to start stream": "Не вдалося запустити потік"
"Failed to start stream": "Не вдалося запустити потік",
"No dictionaries enabled": "Немає увімкнених словників",
"Enable a dictionary in Settings → Language → Dictionaries.": "Увімкніть словник у Налаштування → Мова → Словники.",
"No definitions found": "Визначень не знайдено",
"Search for {{word}} on the web.": "Шукати {{word}} в Інтернеті.",
"Dictionary unsupported": "Словник не підтримується",
"This dictionary format is not supported yet.": "Цей формат словника ще не підтримується.",
"Unable to load the word.": "Не вдалося завантажити слово.",
"Wiktionary": "Вікісловник",
"Drag to reorder": "Перетягніть, щоб змінити порядок",
"Built-in": "Вбудований",
"Bundle is missing on this device. Re-import to use it.": "Пакет відсутній на цьому пристрої. Повторно імпортуйте для використання.",
"This dictionary format is not supported.": "Цей формат словника не підтримується.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_one": "Імпортовано {{count}} словник",
"Imported {{count}} dictionary_few": "Імпортовано {{count}} словники",
"Imported {{count}} dictionary_many": "Імпортовано {{count}} словників",
"Imported {{count}} dictionary_other": "Імпортовано {{count}} словників",
"Skipped incomplete bundles: {{names}}": "Пропущено неповні пакети: {{names}}",
"Failed to import dictionary: {{message}}": "Не вдалося імпортувати словник: {{message}}",
"Dictionaries": "Словники",
"Delete Dictionary": "Видалити словник",
"Importing…": "Імпорт…",
"Import Dictionary": "Імпорт словника",
"No dictionaries available.": "Немає доступних словників.",
"Drag the handle on the left to reorder.": "Перетягуйте маркер ліворуч, щоб змінити порядок.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Пакети StarDict потребують файлів .ifo, .idx і .dict.dz (.syn — необов’язково).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Пакети MDict використовують файли .mdx; супутні .mdd — необов’язково.",
"Select all the bundle files together when importing.": "Під час імпорту вибирайте всі файли пакета разом.",
"Manage Dictionaries": "Керування словниками",
"Select Dictionary Files": "Виберіть файли словника",
"Read on Wikipedia →": "Читати у Вікіпедії →"
}
@@ -781,7 +781,6 @@
"Annotate text after selection": "Chú thích văn bản sau khi chọn",
"Search text after selection": "Tìm kiếm văn bản sau khi chọn",
"Look up text in dictionary after selection": "Tra cứu văn bản trong từ điển sau khi chọn",
"Look up text in Wikipedia after selection": "Tra cứu văn bản trong Wikipedia sau khi chọn",
"Translate text after selection": "Dịch văn bản sau khi chọn",
"Read text aloud after selection": "Đọc to văn bản sau khi chọn",
"Proofread text after selection": "Hiệu đính văn bản sau khi chọn",
@@ -829,8 +828,6 @@
"Export Book": "Xuất sách",
"Whole word:": "Toàn bộ từ:",
"Error": "Lỗi",
"Unable to load the article. Try searching directly on {{link}}.": "Không thể tải bài viết. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Không thể tải từ. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
"Date Published": "Ngày xuất bản",
"Only for TTS:": "Chỉ dành cho TTS:",
"Uploaded": "Đã tải lên",
@@ -1106,7 +1103,6 @@
"Copy Selection": "Sao chép vùng chọn",
"Translate Selection": "Dịch vùng chọn",
"Dictionary Lookup": "Tra từ điển",
"Wikipedia Lookup": "Tra cứu Wikipedia",
"Read Aloud Selection": "Đọc to vùng chọn",
"Proofread Selection": "Kiểm tra vùng chọn",
"Open Settings": "Mở cài đặt",
@@ -1199,5 +1195,34 @@
"Computed Hash": "Hash Đã Tính",
"Identifiers": "Định Danh",
"Read (Stream)": "Đọc (Stream)",
"Failed to start stream": "Không thể bắt đầu stream"
"Failed to start stream": "Không thể bắt đầu stream",
"No dictionaries enabled": "Chưa kích hoạt từ điển nào",
"Enable a dictionary in Settings → Language → Dictionaries.": "Bật từ điển trong Cài đặt → Ngôn ngữ → Từ điển.",
"No definitions found": "Không tìm thấy định nghĩa",
"Search for {{word}} on the web.": "Tìm {{word}} trên web.",
"Dictionary unsupported": "Từ điển không được hỗ trợ",
"This dictionary format is not supported yet.": "Định dạng từ điển này chưa được hỗ trợ.",
"Unable to load the word.": "Không thể tải từ.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Kéo để sắp xếp lại",
"Built-in": "Tích hợp",
"Bundle is missing on this device. Re-import to use it.": "Gói không có trên thiết bị này. Nhập lại để sử dụng.",
"This dictionary format is not supported.": "Định dạng từ điển này không được hỗ trợ.",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_other": "Đã nhập {{count}} từ điển",
"Skipped incomplete bundles: {{names}}": "Đã bỏ qua các gói chưa đầy đủ: {{names}}",
"Failed to import dictionary: {{message}}": "Không thể nhập từ điển: {{message}}",
"Dictionaries": "Từ điển",
"Delete Dictionary": "Xoá từ điển",
"Importing…": "Đang nhập…",
"Import Dictionary": "Nhập từ điển",
"No dictionaries available.": "Không có từ điển.",
"Drag the handle on the left to reorder.": "Kéo tay cầm bên trái để sắp xếp lại.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Gói StarDict cần các tệp .ifo, .idx và .dict.dz (.syn tuỳ chọn).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Gói MDict dùng tệp .mdx; tệp .mdd đi kèm là tuỳ chọn.",
"Select all the bundle files together when importing.": "Chọn tất cả các tệp của gói cùng lúc khi nhập.",
"Manage Dictionaries": "Quản lý từ điển",
"Select Dictionary Files": "Chọn tệp từ điển",
"Read on Wikipedia →": "Đọc trên Wikipedia →"
}
@@ -782,7 +782,6 @@
"Annotate text after selection": "注释选中文本",
"Search text after selection": "搜索选中文本",
"Look up text in dictionary after selection": "在字典中查找选中文本",
"Look up text in Wikipedia after selection": "在维基百科中查找选中文本",
"Translate text after selection": "翻译选中文本",
"Read text aloud after selection": "朗读选中文本",
"Proofread text after selection": "校对选中文本",
@@ -831,8 +830,6 @@
"Whole word:": "全词匹配:",
"Only for TTS:": "仅用于TTS",
"Error": "错误",
"Unable to load the article. Try searching directly on {{link}}.": "无法加载文章。请直接在 {{link}} 上搜索。",
"Unable to load the word. Try searching directly on {{link}}.": "无法加载词汇。请直接在 {{link}} 上搜索。",
"Date Published": "出版日期",
"Uploaded": "已上传",
"Downloaded": "已下载",
@@ -1106,7 +1103,6 @@
"Copy Selection": "复制选中内容",
"Translate Selection": "翻译选中内容",
"Dictionary Lookup": "词典查询",
"Wikipedia Lookup": "维基百科查询",
"Read Aloud Selection": "朗读选中内容",
"Proofread Selection": "校对选中内容",
"Open Settings": "打开设置",
@@ -1199,5 +1195,34 @@
"Computed Hash": "计算的哈希",
"Identifiers": "标识符",
"Read (Stream)": "阅读(流式)",
"Failed to start stream": "启动流式传输失败"
"Failed to start stream": "启动流式传输失败",
"No dictionaries enabled": "未启用任何词典",
"Enable a dictionary in Settings → Language → Dictionaries.": "在 设置 → 语言 → 词典 中启用词典。",
"No definitions found": "未找到释义",
"Search for {{word}} on the web.": "在网页上搜索 {{word}}。",
"Dictionary unsupported": "词典不受支持",
"This dictionary format is not supported yet.": "尚不支持此词典格式。",
"Unable to load the word.": "无法加载该词。",
"Wiktionary": "维基词典",
"Drag to reorder": "拖动以重新排序",
"Built-in": "内置",
"Bundle is missing on this device. Re-import to use it.": "此设备上缺少词典文件。请重新导入以使用。",
"This dictionary format is not supported.": "不支持此词典格式。",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_other": "已导入 {{count}} 个词典",
"Skipped incomplete bundles: {{names}}": "已跳过不完整的词典包:{{names}}",
"Failed to import dictionary: {{message}}": "导入词典失败:{{message}}",
"Dictionaries": "词典",
"Delete Dictionary": "删除词典",
"Importing…": "正在导入…",
"Import Dictionary": "导入词典",
"No dictionaries available.": "没有可用的词典。",
"Drag the handle on the left to reorder.": "拖动左侧手柄以重新排序。",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict 词典包需要 .ifo、.idx 和 .dict.dz 文件(.syn 可选)。",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict 词典包使用 .mdx 文件;配套的 .mdd 文件为可选。",
"Select all the bundle files together when importing.": "导入时请同时选择词典包内的所有文件。",
"Manage Dictionaries": "管理词典",
"Select Dictionary Files": "选择词典文件",
"Read on Wikipedia →": "在维基百科上阅读 →"
}
@@ -781,7 +781,6 @@
"Annotate text after selection": "註解選取後的文字",
"Search text after selection": "搜尋選取後的文字",
"Look up text in dictionary after selection": "在字典中查找選取後的文字",
"Look up text in Wikipedia after selection": "在維基百科中查找選取後的文字",
"Translate text after selection": "翻譯選取後的文字",
"Read text aloud after selection": "朗讀選取後的文字",
"Proofread text after selection": "校對選取後的文字",
@@ -829,8 +828,6 @@
"Export Book": "匯出書籍",
"Whole word:": "全詞匹配:",
"Error": "錯誤",
"Unable to load the article. Try searching directly on {{link}}.": "無法載入文章。請直接在 {{link}} 上搜尋。",
"Unable to load the word. Try searching directly on {{link}}.": "無法載入詞彙。請直接在 {{link}} 上搜尋。",
"Date Published": "出版日期",
"Only for TTS:": "僅用於TTS",
"Uploaded": "已上傳",
@@ -1106,7 +1103,6 @@
"Copy Selection": "複製選取內容",
"Translate Selection": "翻譯選取內容",
"Dictionary Lookup": "字典查詢",
"Wikipedia Lookup": "維基百科查詢",
"Read Aloud Selection": "朗讀選取內容",
"Proofread Selection": "校對選取內容",
"Open Settings": "開啟設定",
@@ -1199,5 +1195,34 @@
"Computed Hash": "計算所得雜湊",
"Identifiers": "識別碼",
"Read (Stream)": "閱讀(串流)",
"Failed to start stream": "無法啟動串流"
"Failed to start stream": "無法啟動串流",
"No dictionaries enabled": "未啟用任何詞典",
"Enable a dictionary in Settings → Language → Dictionaries.": "在 設定 → 語言 → 詞典 中啟用詞典。",
"No definitions found": "未找到釋義",
"Search for {{word}} on the web.": "在網頁上搜尋 {{word}}。",
"Dictionary unsupported": "詞典不支援",
"This dictionary format is not supported yet.": "尚不支援此詞典格式。",
"Unable to load the word.": "無法載入此詞。",
"Wiktionary": "維基詞典",
"Drag to reorder": "拖曳以重新排序",
"Built-in": "內建",
"Bundle is missing on this device. Re-import to use it.": "此裝置上缺少詞典檔案。請重新匯入以使用。",
"This dictionary format is not supported.": "不支援此詞典格式。",
"MDict": "MDict",
"StarDict": "StarDict",
"Imported {{count}} dictionary_other": "已匯入 {{count}} 個詞典",
"Skipped incomplete bundles: {{names}}": "已略過不完整的詞典套件:{{names}}",
"Failed to import dictionary: {{message}}": "匯入詞典失敗:{{message}}",
"Dictionaries": "詞典",
"Delete Dictionary": "刪除詞典",
"Importing…": "正在匯入…",
"Import Dictionary": "匯入詞典",
"No dictionaries available.": "沒有可用的詞典。",
"Drag the handle on the left to reorder.": "拖曳左側握把以重新排序。",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict 詞典套件需要 .ifo、.idx 和 .dict.dz 檔案(.syn 選用)。",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict 詞典套件使用 .mdx 檔案;搭配的 .mdd 檔案為選用。",
"Select all the bundle files together when importing.": "匯入時請同時選擇詞典套件內的所有檔案。",
"Manage Dictionaries": "管理詞典",
"Select Dictionary Files": "選擇詞典檔案",
"Read on Wikipedia →": "在維基百科上閱讀 →"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,9 @@
StarDict's dict ifo file
version=2.4.2
wordcount=105626
idxfilesize=1725758
bookname=CMU American English spelling
author=Ruslan Fedyarov
email=fedyarov@mail.ru
date=2006.12.21
sametypesequence=m
@@ -0,0 +1,8 @@
StarDict's dict ifo file
version=3.0.0
bookname=English-Dutch FreeDict Dictionary (en-nl)
wordcount=7714
idxfilesize=134155
sametypesequence=h
website=http://freedict.org/
description=Publisher: FreeDict<br>Copyright (C) 1999-2017 by various authors listed below.<br>Available under the terms of the <a href="https://www.gnu.org/licenses/gpl-2.0.html">GNU General Public License ver. 2.0 and any later version</a>.<br>This Database was generated from ergane, <a href="http://www.travlang.com">http://www.travlang.com</a>.<br>This dictionary comes to you through nice people making it available for free and for<br>good. It is part of the FreeDict project, <a href="http://freedict.org/">http://freedict.org/</a>. This project aims to make translating<br>dictionaries available for free. Your contributions are welcome!
@@ -63,7 +63,6 @@ describe('No identical keybinding lists across actions (#3675)', () => {
// class of bug as #3675.
const KNOWN_PAIRS: ReadonlySet<string> = new Set([
'onSearchSelection,onShowSearchBar', // ctrl+f / cmd+f
'onCloseWindow,onWikipediaSelection', // ctrl+w / cmd+w
]);
it('should not have two actions with exactly the same key list', async () => {
@@ -0,0 +1,113 @@
/**
* Byte-counting FileSystem wrapper for dictionary-provider perf tests.
*
* Wraps a real `DictionaryFileOpener` so every byte that flows out of a
* `File.slice(...).arrayBuffer()` (or a direct `File.arrayBuffer()`) gets
* tallied. Used to catch perf regressions like "lookup accidentally
* re-reads the whole dict file" or "init reads way more than necessary".
*
* Tracks reads per-file so individual assertions can target a specific
* bundle file (e.g. the .dict.dz vs the .idx).
*/
import type { BaseDir } from '@/types/system';
import type { DictionaryFileOpener } from '@/services/dictionaries/providers/starDictProvider';
export interface ReadCounter {
/** Total bytes read across all files. */
total: number;
/** Per-file (basename) byte tallies. */
perFile: Map<string, number>;
}
export const makeReadCounter = (): ReadCounter => ({ total: 0, perFile: new Map() });
const bump = (counter: ReadCounter, name: string, n: number) => {
counter.total += n;
counter.perFile.set(name, (counter.perFile.get(name) ?? 0) + n);
};
class CountingBlob extends Blob {
constructor(
private inner: Blob,
private counter: ReadCounter,
private name: string,
) {
super();
}
override get size() {
return this.inner.size;
}
override get type() {
return this.inner.type;
}
override slice(start?: number, end?: number, contentType?: string): Blob {
return new CountingBlob(this.inner.slice(start, end, contentType), this.counter, this.name);
}
override async arrayBuffer(): Promise<ArrayBuffer> {
const buf = await this.inner.arrayBuffer();
bump(this.counter, this.name, buf.byteLength);
return buf;
}
override async text(): Promise<string> {
const t = await this.inner.text();
bump(this.counter, this.name, new TextEncoder().encode(t).byteLength);
return t;
}
// `stream()` deliberately not overridden — none of the dictionary code
// paths call it, and the inner Blob's `stream()` type isn't assignable to
// Blob's stricter overload signature in current lib.dom typings.
}
class CountingFile extends File {
constructor(
private inner: Blob,
name: string,
private counter: ReadCounter,
) {
super([], name);
}
override get size() {
return this.inner.size;
}
override get type() {
return this.inner.type;
}
override slice(start?: number, end?: number, contentType?: string): Blob {
return new CountingBlob(this.inner.slice(start, end, contentType), this.counter, this.name);
}
override async arrayBuffer(): Promise<ArrayBuffer> {
const buf = await this.inner.arrayBuffer();
bump(this.counter, this.name, buf.byteLength);
return buf;
}
override async text(): Promise<string> {
const t = await this.inner.text();
bump(this.counter, this.name, new TextEncoder().encode(t).byteLength);
return t;
}
// `stream()` deliberately not overridden — see CountingBlob.
}
/** Wrap a `DictionaryFileOpener` so all reads go through `counter`. */
export function withReadCounting(
inner: DictionaryFileOpener,
counter: ReadCounter,
): DictionaryFileOpener {
return {
openFile: async (path: string, base: BaseDir) => {
const file = await inner.openFile(path, base);
const basename = path.split('/').pop()!;
return new CountingFile(file, basename, counter);
},
};
}
@@ -0,0 +1,34 @@
/**
* Shared MDict test fixture loader.
*
* Tests and benchmarks point at the same on-disk bundle in
* `src/__tests__/fixtures/data/dicts/`. Drop in any real `.mdx` / `.mdd`
* pair (rename to `mdict-en-en.*`) to exercise the production code path
* against real-world data without changing test code.
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/data/dicts');
export const MDX_FIXTURE_PATH = path.join(FIXTURES_DIR, 'mdict-en-en.mdx');
export const MDD_FIXTURE_PATH = path.join(FIXTURES_DIR, 'mdict-en-en.mdd');
export const MDX_FIXTURE_NAME = 'mdict-en-en.mdx';
export const MDD_FIXTURE_NAME = 'mdict-en-en.mdd';
/** Read the .mdx as a fresh-ArrayBuffer-backed File suitable for BlobScanner. */
export async function readMdxFile(): Promise<File> {
const bytes = await readFile(MDX_FIXTURE_PATH);
const buf = new Uint8Array(bytes.length);
buf.set(bytes);
return new File([buf], MDX_FIXTURE_NAME);
}
/** Read the .mdd as a fresh-ArrayBuffer-backed File. */
export async function readMddFile(): Promise<File> {
const bytes = await readFile(MDD_FIXTURE_PATH);
const buf = new Uint8Array(bytes.length);
buf.set(bytes);
return new File([buf], MDD_FIXTURE_NAME);
}
@@ -0,0 +1,34 @@
/**
* Shared StarDict test fixture loader.
*
* Points at a real bundle in `src/__tests__/fixtures/data/dicts/`. Drop in
* any real StarDict bundle (rename the four files to `cmudict.*`) to
* exercise the production code path against your own data.
*
* The default fixture (CMU American English spelling, 105,626 entries,
* `sametypesequence=m`) is small enough to keep the suite fast but large
* enough to exercise multi-block reads.
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/data/dicts');
export const IFO_FIXTURE_PATH = path.join(FIXTURES_DIR, 'cmudict.ifo');
export const IDX_FIXTURE_PATH = path.join(FIXTURES_DIR, 'cmudict.idx');
export const DICT_FIXTURE_PATH = path.join(FIXTURES_DIR, 'cmudict.dict.dz');
export const IFO_FIXTURE_NAME = 'cmudict.ifo';
export const IDX_FIXTURE_NAME = 'cmudict.idx';
export const DICT_FIXTURE_NAME = 'cmudict.dict.dz';
async function readAsFile(filePath: string, name: string): Promise<File> {
const bytes = await readFile(filePath);
const buf = new Uint8Array(bytes.length);
buf.set(bytes);
return new File([buf], name);
}
export const readIfoFile = () => readAsFile(IFO_FIXTURE_PATH, IFO_FIXTURE_NAME);
export const readIdxFile = () => readAsFile(IDX_FIXTURE_PATH, IDX_FIXTURE_NAME);
export const readDictFile = () => readAsFile(DICT_FIXTURE_PATH, DICT_FIXTURE_NAME);
@@ -0,0 +1,114 @@
/**
* Diagnostic + benchmark for js-mdict init on a real bundle.
*
* Drop your own large `.mdx` (rename to `mdict-en-en.mdx`) into
* `src/__tests__/fixtures/data/dicts/` to reproduce the user-side hangs.
* The test prints a structured report read it; nothing here is a hard
* regression guard.
*
* What we measure:
* - Total `MDX.create(blob)` time
* - Number of BlobScanner reads + total bytes read
* - Per-read latency (slowest 5)
* - Reads attributable to `_readKeyBlocks` (the upstream-flagged "very slow"
* eager step) counted by clustering reads after the key-info read
* - Time + reads for a `mdx.lookup(headword)` call afterwards
*/
import { describe, it, expect } from 'vitest';
import { performance } from 'node:perf_hooks';
import { BlobScanner, MDX } from 'js-mdict';
import { readMdxFile, MDX_FIXTURE_PATH } from './_mdictFixtures';
interface ReadEvent {
offset: number;
length: number;
durationMs: number;
/** Phase tag set by the test harness (e.g. 'init' or 'lookup'). */
phase: string;
}
class InstrumentedBlobScanner extends BlobScanner {
reads: ReadEvent[] = [];
totalBytesRead = 0;
phase = 'init';
override async readBuffer(offset: number | bigint, length: number): Promise<Uint8Array> {
const start = performance.now();
const result = await super.readBuffer(offset, length);
const durationMs = performance.now() - start;
this.reads.push({
offset: typeof offset === 'bigint' ? Number(offset) : offset,
length,
durationMs,
phase: this.phase,
});
this.totalBytesRead += length;
return result;
}
}
describe('mdict init diagnostic', () => {
it('reports init read pattern + lookup cost on the shared fixture', async () => {
const file = await readMdxFile();
const scanner = new InstrumentedBlobScanner(file);
// --- INIT ---
const t0 = performance.now();
const mdx = new MDX(scanner, file.name);
await mdx.init();
const initMs = performance.now() - t0;
const initReads = scanner.reads.length;
const initBytes = scanner.totalBytesRead;
// --- LOOKUP ---
scanner.phase = 'lookup';
const before = scanner.reads.length;
const beforeBytes = scanner.totalBytesRead;
const tLookup = performance.now();
const result = await mdx.lookup('abandon');
const lookupMs = performance.now() - tLookup;
const lookupReads = scanner.reads.length - before;
const lookupBytes = scanner.totalBytesRead - beforeBytes;
const slowest = [...scanner.reads]
.sort((a, b) => b.durationMs - a.durationMs)
.slice(0, 5)
.map((r) => ({
offset: r.offset,
length: r.length,
durationMs: Math.round(r.durationMs * 100) / 100,
phase: r.phase,
}));
console.log(
JSON.stringify(
{
fixture: MDX_FIXTURE_PATH,
fileSize: file.size,
encrypt: mdx.meta.encrypt,
version: mdx.meta.version,
keywordCount: mdx.keywordList.length,
init: {
ms: Math.round(initMs * 100) / 100,
reads: initReads,
bytesRead: initBytes,
},
lookup: {
word: 'abandon',
found: !!result.definition,
ms: Math.round(lookupMs * 100) / 100,
reads: lookupReads,
bytesRead: lookupBytes,
},
slowestReads: slowest,
},
null,
2,
),
);
expect(initReads).toBeGreaterThan(0);
expect(result.keyText).toBe('abandon');
});
});
@@ -0,0 +1,307 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createMdictProvider } from '@/services/dictionaries/providers/mdictProvider';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { BaseDir } from '@/types/system';
import { MDX_FIXTURE_NAME, MDD_FIXTURE_NAME, readMdxFile, readMddFile } from './_mdictFixtures';
import { makeReadCounter, withReadCounting } from './_countingFs';
// The shared fixture is a real MDict bundle (Longman Phrasal Verbs Dictionary,
// encrypt=2 / key-info-encrypted variant). Drop in any real .mdx/.mdd pair
// renamed to `mdict-en-en.*` to exercise this suite against your own data.
const KNOWN_HEADWORD = 'abandon';
const UNKNOWN_HEADWORD = 'zzznotaword';
const objectUrls: string[] = [];
const originalCreate = globalThis.URL.createObjectURL;
const originalRevoke = globalThis.URL.revokeObjectURL;
beforeEach(() => {
objectUrls.length = 0;
// jsdom lacks URL.createObjectURL — stub it so the provider's image
// substitution path exercises end-to-end.
globalThis.URL.createObjectURL = (blob: Blob) => {
const url = `blob:test/${objectUrls.length}-${blob.size}`;
objectUrls.push(url);
return url;
};
globalThis.URL.revokeObjectURL = () => {};
return () => {
globalThis.URL.createObjectURL = originalCreate;
globalThis.URL.revokeObjectURL = originalRevoke;
};
});
const buildDict = (withMdd = true): ImportedDictionary => ({
id: 'mdict:fixture',
kind: 'mdict',
name: 'Fixture',
bundleDir: 'fixture-bundle',
files: {
mdx: MDX_FIXTURE_NAME,
mdd: withMdd ? [MDD_FIXTURE_NAME] : undefined,
},
addedAt: 1,
});
const makeFs = () => ({
openFile: async (p: string, _base: BaseDir) => {
const base = p.split('/').pop()!;
if (base === MDX_FIXTURE_NAME) return readMdxFile();
if (base === MDD_FIXTURE_NAME) return readMddFile();
throw new Error(`Unknown fixture file: ${base}`);
},
});
describe('mdictProvider', () => {
it('opens an .mdx via Blob and looks up a real entry', async () => {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup(KNOWN_HEADWORD, {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
if (outcome.ok) expect(outcome.headword).toBe(KNOWN_HEADWORD);
expect(container.querySelector('h1')?.textContent).toBe(KNOWN_HEADWORD);
expect(container.querySelector('div')?.innerHTML.length).toBeGreaterThan(0);
});
it('returns empty for an unknown headword', async () => {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup(UNKNOWN_HEADWORD, {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('empty');
});
it('shares the parsed instance across consecutive lookups', async () => {
const fs = makeFs();
let opens = 0;
const wrappedFs = {
openFile: async (p: string, base: BaseDir) => {
opens += 1;
return fs.openFile(p, base);
},
};
const provider = createMdictProvider({ dict: buildDict(false), fs: wrappedFs });
const container = document.createElement('div');
await provider.lookup(KNOWN_HEADWORD, {
signal: new AbortController().signal,
container,
});
const firstOpens = opens;
container.replaceChildren();
await provider.lookup(KNOWN_HEADWORD, {
signal: new AbortController().signal,
container,
});
expect(opens).toBe(firstOpens);
});
it('handles encrypt=2 (key-info-only) dictionaries — the fixture itself is encrypt=2', async () => {
// The shared fixture's `meta.encrypt === 2`, so this is verified by the
// first test passing. Lock the contract explicitly here as well.
const jsmdict = await import('js-mdict');
const file = await readMdxFile();
const mdx = await jsmdict.MDX.create(file);
expect(mdx.meta.encrypt).toBe(2);
});
it('rejects encrypt=1 (record-block / passcode) dictionaries as unsupported', async () => {
const jsmdict = await import('js-mdict');
const origCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
jsmdict.MDX.create = async (file: Blob) => {
const inst = await origCreate(file);
(inst as unknown as { meta: { encrypt: number } }).meta.encrypt = 1;
return inst;
};
try {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup(KNOWN_HEADWORD, {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('unsupported');
} finally {
jsmdict.MDX.create = origCreate;
}
});
it('classifies upstream "encrypted file" throws as `unsupported`, not `error`', async () => {
const jsmdict = await import('js-mdict');
const origCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
jsmdict.MDX.create = async () => {
throw new Error('user identification is needed to read encrypted file');
};
try {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup(KNOWN_HEADWORD, {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('unsupported');
} finally {
jsmdict.MDX.create = origCreate;
}
});
it('init failure surfaces as `error`', async () => {
const failingFs = {
openFile: async () => {
throw new Error('disk gone');
},
};
const provider = createMdictProvider({ dict: buildDict(false), fs: failingFs });
const container = document.createElement('div');
const outcome = await provider.lookup(KNOWN_HEADWORD, {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) {
expect(outcome.reason).toBe('error');
expect(outcome.message).toContain('disk gone');
}
});
it('dispose() revokes object URLs created for image resources', async () => {
let revoked = 0;
globalThis.URL.revokeObjectURL = () => {
revoked += 1;
};
const provider = createMdictProvider({ dict: buildDict(true), fs: makeFs() });
const container = document.createElement('div');
// Manually inject an <img> referencing an mdd resource to force the
// resolver path. We don't depend on the definition containing one;
// the resolver runs on the rendered body.
await provider.lookup(KNOWN_HEADWORD, {
signal: new AbortController().signal,
container,
});
const body = container.querySelector('div')!;
const img = document.createElement('img');
img.setAttribute('src', 'nonexistent.png');
body.appendChild(img);
// Re-run lookup so the resolver iterates over the freshly-added <img>.
container.replaceChildren();
await provider.lookup(KNOWN_HEADWORD, {
signal: new AbortController().signal,
container,
});
provider.dispose?.();
// Either tracked URLs were created or weren't; the key assertion is
// dispose() runs without throwing in both cases.
expect(typeof revoked).toBe('number');
});
// -------------------------------------------------------------------------
// Perf regression — bytes read.
//
// MDict's BlobScanner reads slices on demand. After init, a single lookup
// should read at most one record block (typically a few KB to tens of KB).
// If a future change accidentally re-reads the keylist or scans for a
// matching word linearly, these tests catch it.
//
// Measured baselines on the Longman fixture (5,587 entries, encrypt=2):
// - init (incl. eager `_readKeyBlocks` traversal): ~50 KB
// - per-lookup (one inflated record block): ~7.4 KB
// - not-found word (binary-search early-out): 0 bytes
//
// The 64 KB ceiling absorbs format variance (some bundles use larger
// record blocks).
// -------------------------------------------------------------------------
it('per-lookup reads less than 64 KB after init', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeFs(), counter);
const provider = createMdictProvider({ dict: buildDict(false), fs });
// Init via the first lookup.
const c1 = document.createElement('div');
await provider.lookup(KNOWN_HEADWORD, {
signal: new AbortController().signal,
container: c1,
});
const initBytes = counter.total;
expect(initBytes).toBeGreaterThan(0);
// Real headwords from the Longman fixture (verified to exist via probe:
// first 5 entries are abandon, abandon to, abide, abide by, abound).
for (const word of ['abide', 'abound', 'abide by']) {
const before = counter.total;
const c = document.createElement('div');
await provider.lookup(word, { signal: new AbortController().signal, container: c });
const delta = counter.total - before;
expect(delta).toBeGreaterThan(0); // Word found → at least one record-block read
expect(delta).toBeLessThan(64 * 1024);
}
});
it('lookup of an unknown headword reads 0 bytes (binary-search early-out)', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeFs(), counter);
const provider = createMdictProvider({ dict: buildDict(false), fs });
// Prime init with a known lookup first.
await provider.lookup(KNOWN_HEADWORD, {
signal: new AbortController().signal,
container: document.createElement('div'),
});
const before = counter.total;
await provider.lookup(UNKNOWN_HEADWORD, {
signal: new AbortController().signal,
container: document.createElement('div'),
});
expect(counter.total - before).toBe(0);
});
it('init does not read the entire .mdx file', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeFs(), counter);
const provider = createMdictProvider({ dict: buildDict(false), fs });
const c = document.createElement('div');
await provider.lookup(KNOWN_HEADWORD, { signal: new AbortController().signal, container: c });
const realMdx = await readMdxFile();
const mdxBytesRead = counter.perFile.get(MDX_FIXTURE_NAME) ?? 0;
// Init reads keylist + headers + the lookup's record block — not full
// record content. Bound at half the file size; real ratio on Longman
// is ~4%.
expect(mdxBytesRead).toBeGreaterThan(0);
expect(mdxBytesRead).toBeLessThan(realMdx.size * 0.5);
});
it('repeated lookups of the same word produce identical, bounded reads', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeFs(), counter);
const provider = createMdictProvider({ dict: buildDict(false), fs });
const run = async () => {
await provider.lookup(KNOWN_HEADWORD, {
signal: new AbortController().signal,
container: document.createElement('div'),
});
};
await run(); // includes init
const afterFirst = counter.total;
await run();
const secondDelta = counter.total - afterFirst;
await run();
const thirdDelta = counter.total - afterFirst - secondDelta;
// js-mdict doesn't cache record blocks. Repeats re-read the same block
// — assert deltas are equal (no creeping growth) and small.
expect(secondDelta).toBeGreaterThan(0);
expect(secondDelta).toBeLessThan(64 * 1024);
expect(thirdDelta).toBe(secondDelta);
});
});
@@ -0,0 +1,111 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getEnabledProviders, __resetRegistryForTests } from '@/services/dictionaries/registry';
import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
import type { DictionarySettings, ImportedDictionary } from '@/services/dictionaries/types';
const baseSettings: DictionarySettings = {
providerOrder: [BUILTIN_PROVIDER_IDS.wiktionary, BUILTIN_PROVIDER_IDS.wikipedia],
providerEnabled: {
[BUILTIN_PROVIDER_IDS.wiktionary]: true,
[BUILTIN_PROVIDER_IDS.wikipedia]: true,
},
};
describe('dictionary registry', () => {
beforeEach(() => {
__resetRegistryForTests();
});
it('returns builtin providers in order, both enabled', () => {
const providers = getEnabledProviders({ settings: baseSettings, dictionaries: [] });
expect(providers.map((p) => p.id)).toEqual([
BUILTIN_PROVIDER_IDS.wiktionary,
BUILTIN_PROVIDER_IDS.wikipedia,
]);
});
it('skips providers explicitly disabled', () => {
const providers = getEnabledProviders({
settings: {
...baseSettings,
providerEnabled: {
...baseSettings.providerEnabled,
[BUILTIN_PROVIDER_IDS.wikipedia]: false,
},
},
dictionaries: [],
});
expect(providers.map((p) => p.id)).toEqual([BUILTIN_PROVIDER_IDS.wiktionary]);
});
it('honors providerOrder regardless of declaration order', () => {
const providers = getEnabledProviders({
settings: {
...baseSettings,
providerOrder: [BUILTIN_PROVIDER_IDS.wikipedia, BUILTIN_PROVIDER_IDS.wiktionary],
},
dictionaries: [],
});
expect(providers.map((p) => p.id)).toEqual([
BUILTIN_PROVIDER_IDS.wikipedia,
BUILTIN_PROVIDER_IDS.wiktionary,
]);
});
it('caches the same provider instance across calls', () => {
const a = getEnabledProviders({ settings: baseSettings, dictionaries: [] });
const b = getEnabledProviders({ settings: baseSettings, dictionaries: [] });
expect(a[0]).toBe(b[0]);
});
it('skips imported dictionaries that are unavailable, deleted, or unsupported', () => {
const fs = { openFile: async () => new File([], '') };
const dicts: ImportedDictionary[] = [
{
id: 'mdict:available',
kind: 'mdict',
name: 'Available',
bundleDir: 'a',
files: { mdx: 'a.mdx' },
addedAt: 1,
},
{
id: 'mdict:gone',
kind: 'mdict',
name: 'Gone',
bundleDir: 'g',
files: { mdx: 'g.mdx' },
addedAt: 2,
unavailable: true,
},
{
id: 'stardict:nope',
kind: 'stardict',
name: 'Nope',
bundleDir: 'n',
files: { ifo: 'n.ifo' },
addedAt: 3,
unsupported: true,
},
];
const settings: DictionarySettings = {
providerOrder: [
BUILTIN_PROVIDER_IDS.wiktionary,
'mdict:available',
'mdict:gone',
'stardict:nope',
],
providerEnabled: {
[BUILTIN_PROVIDER_IDS.wiktionary]: true,
'mdict:available': true,
'mdict:gone': true,
'stardict:nope': true,
},
};
const providers = getEnabledProviders({ settings, dictionaries: dicts, fs });
expect(providers.map((p) => p.id)).toEqual([
BUILTIN_PROVIDER_IDS.wiktionary,
'mdict:available',
]);
});
});
@@ -0,0 +1,625 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { gzipSync } from 'node:zlib';
import { createStarDictProvider } from '@/services/dictionaries/providers/starDictProvider';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { BaseDir } from '@/types/system';
// ---------------------------------------------------------------------------
// Minimal StarDict fixture builder for edge-case tests.
//
// Produces `.ifo`, `.idx`, plain-gzipped `.dict.dz`, and (optionally) `.syn`
// files. The provider's reader (`StarDictReader`) gunzips the whole `.dict`
// once at init and slices by offset, which works on real-world `.dict.dz`
// files regardless of whether they were produced with proper DictZip
// random-access boundaries — so the fixture builder doesn't need to bother
// with FEXTRA / RA. Single-type `sametypesequence=m` (plain text) matches
// v1's StarDict scope.
// ---------------------------------------------------------------------------
interface Entry {
word: string;
text: string;
}
function concatU8(parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const p of parts) {
out.set(p, pos);
pos += p.length;
}
return out;
}
function writeUint32BE(buf: Uint8Array, offset: number, value: number) {
buf[offset] = (value >>> 24) & 0xff;
buf[offset + 1] = (value >>> 16) & 0xff;
buf[offset + 2] = (value >>> 8) & 0xff;
buf[offset + 3] = value & 0xff;
}
/** Build a `.dict` blob (uncompressed concatenation) and the per-entry index. */
function buildDictAndIndex(entries: Entry[]) {
const enc = new TextEncoder();
const dictParts: Uint8Array[] = [];
const indexEntries: { word: string; offset: number; size: number }[] = [];
let pos = 0;
for (const e of entries) {
const data = enc.encode(e.text);
indexEntries.push({ word: e.word, offset: pos, size: data.length });
dictParts.push(data);
pos += data.length;
}
return { dict: concatU8(dictParts), indexEntries };
}
/** Build a `.idx` blob (word\0 offset_be size_be ...). */
function buildIdx(indexEntries: { word: string; offset: number; size: number }[]) {
const enc = new TextEncoder();
const parts: Uint8Array[] = [];
// .idx must be sorted by word for the binary search in dict.js.
const sorted = [...indexEntries].sort((a, b) =>
a.word.toLowerCase() < b.word.toLowerCase()
? -1
: a.word.toLowerCase() > b.word.toLowerCase()
? 1
: 0,
);
for (const e of sorted) {
parts.push(enc.encode(e.word));
const tail = new Uint8Array(1 + 8); // null terminator + offset + size
writeUint32BE(tail, 1, e.offset);
writeUint32BE(tail, 5, e.size);
parts.push(tail);
}
return concatU8(parts);
}
/** Build a `.syn` blob (synonym\0 idx_be ...) where idx_be points into `.idx`. */
function buildSyn(synonyms: { word: string; idxIndex: number }[]) {
const enc = new TextEncoder();
const parts: Uint8Array[] = [];
const sorted = [...synonyms].sort((a, b) =>
a.word.toLowerCase() < b.word.toLowerCase()
? -1
: a.word.toLowerCase() > b.word.toLowerCase()
? 1
: 0,
);
for (const s of sorted) {
parts.push(enc.encode(s.word));
const tail = new Uint8Array(1 + 4);
writeUint32BE(tail, 1, s.idxIndex);
parts.push(tail);
}
return concatU8(parts);
}
/** Build a `.dict.dz` blob — just gzip the raw `.dict` bytes. */
function buildDictDz(dict: Uint8Array): Uint8Array {
return new Uint8Array(gzipSync(Buffer.from(dict)));
}
/** Build a full StarDict bundle for the given entries. */
function buildBundle(entries: Entry[], synonyms: { word: string; idxIndex: number }[] = []) {
const ifo = new TextEncoder().encode(
[
"StarDict's dict ifo file",
'version=2.4.2',
'bookname=Test Dictionary',
`wordcount=${entries.length}`,
`synwordcount=${synonyms.length}`,
'sametypesequence=m',
'',
].join('\n'),
);
const { dict, indexEntries } = buildDictAndIndex(entries);
const idx = buildIdx(indexEntries);
const dictDz = buildDictDz(dict);
const syn = synonyms.length ? buildSyn(synonyms) : undefined;
return { ifo, idx, dictDz, syn };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('starDictProvider', () => {
let bundleFiles: { ifo: Uint8Array; idx: Uint8Array; dictDz: Uint8Array; syn?: Uint8Array };
beforeEach(() => {
bundleFiles = buildBundle(
[
{ word: 'apple', text: 'a fruit' },
{ word: 'banana', text: 'another fruit' },
{ word: 'cherry', text: 'a small red fruit' },
],
// synonym 'pomme' → idx entry 0 ('apple')
[{ word: 'pomme', idxIndex: 0 }],
);
});
const buildDict = (files = bundleFiles): ImportedDictionary => ({
id: 'stardict:test',
kind: 'stardict',
name: 'Test Dictionary',
bundleDir: 'test-bundle',
files: {
ifo: 'test.ifo',
idx: 'test.idx',
dict: 'test.dict.dz',
syn: files.syn ? 'test.syn' : undefined,
},
addedAt: 1,
});
const makeFs = (files = bundleFiles) => ({
openFile: async (path: string, _base: BaseDir) => {
const base = path.split('/').pop()!;
let bytes: Uint8Array;
if (base === 'test.ifo') bytes = files.ifo;
else if (base === 'test.idx') bytes = files.idx;
else if (base === 'test.dict.dz') bytes = files.dictDz;
else if (base === 'test.syn' && files.syn) bytes = files.syn;
else throw new Error(`Unknown fixture file: ${base}`);
// Copy into a fresh ArrayBuffer to satisfy BlobPart's Uint8Array<ArrayBuffer> constraint.
const buf = new Uint8Array(bytes.length);
buf.set(bytes);
return new File([buf], base);
},
});
it('initializes lazily and looks up a headword', async () => {
const provider = createStarDictProvider({ dict: buildDict(), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('apple', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
if (outcome.ok) expect(outcome.headword).toBe('apple');
expect(container.querySelector('h1')?.textContent).toBe('apple');
expect(container.querySelector('pre')?.textContent).toBe('a fruit');
});
it('returns empty when the headword does not exist', async () => {
const provider = createStarDictProvider({ dict: buildDict(), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('zzzz', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('empty');
});
it('falls back to synonym lookup when direct match fails', async () => {
const provider = createStarDictProvider({ dict: buildDict(), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('pomme', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
expect(container.querySelector('h1')?.textContent).toBe('apple');
expect(container.querySelector('pre')?.textContent).toBe('a fruit');
});
it('shares the parsed instance across consecutive lookups', async () => {
const fs = makeFs();
let openCount = 0;
const wrappedFs = {
openFile: async (path: string, base: BaseDir) => {
openCount += 1;
return fs.openFile(path, base);
},
};
const provider = createStarDictProvider({ dict: buildDict(), fs: wrappedFs });
const container = document.createElement('div');
await provider.lookup('apple', { signal: new AbortController().signal, container });
const opensAfterFirst = openCount;
container.replaceChildren();
await provider.lookup('banana', { signal: new AbortController().signal, container });
expect(openCount).toBe(opensAfterFirst);
});
it('reports `error` on init failure', async () => {
const failingFs = {
openFile: async () => {
throw new Error('disk gone');
},
};
const provider = createStarDictProvider({ dict: buildDict(), fs: failingFs });
const container = document.createElement('div');
const outcome = await provider.lookup('apple', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) {
expect(outcome.reason).toBe('error');
expect(outcome.message).toContain('disk gone');
}
});
});
// ---------------------------------------------------------------------------
// Real-fixture tests — exercise the production code path against a real
// StarDict bundle (CMU American English spelling, 105,626 entries,
// `sametypesequence=m`, DictZip-compressed). The synthetic-fixture tests
// above cover edge cases the real data doesn't have (.syn fallback, init
// failure); these tests verify the happy path against real bytes.
// ---------------------------------------------------------------------------
import {
IFO_FIXTURE_NAME,
IDX_FIXTURE_NAME,
DICT_FIXTURE_NAME,
readIfoFile,
readIdxFile,
readDictFile,
} from './_stardictFixtures';
import { makeReadCounter, withReadCounting } from './_countingFs';
describe('starDictProvider — real cmudict fixture', () => {
const realDict: ImportedDictionary = {
id: 'stardict:cmudict',
kind: 'stardict',
name: 'CMU American English spelling',
bundleDir: 'cmudict-bundle',
files: {
ifo: IFO_FIXTURE_NAME,
idx: IDX_FIXTURE_NAME,
dict: DICT_FIXTURE_NAME,
},
addedAt: 1,
};
const makeRealFs = () => ({
openFile: async (p: string, _base: BaseDir) => {
const base = p.split('/').pop()!;
if (base === IFO_FIXTURE_NAME) return readIfoFile();
if (base === IDX_FIXTURE_NAME) return readIdxFile();
if (base === DICT_FIXTURE_NAME) return readDictFile();
throw new Error(`Unknown fixture file: ${base}`);
},
});
it('looks up a real headword and renders the definition', async () => {
const provider = createStarDictProvider({ dict: realDict, fs: makeRealFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('hello', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
if (outcome.ok) {
expect(outcome.headword).toBe('hello');
expect(outcome.sourceLabel).toBe('CMU American English spelling');
}
expect(container.querySelector('h1')?.textContent).toBe('hello');
const def = container.querySelector('pre')?.textContent ?? '';
expect(def.length).toBeGreaterThan(0);
});
it('returns the same definition shape for several common headwords', async () => {
const provider = createStarDictProvider({ dict: realDict, fs: makeRealFs() });
for (const word of ['cat', 'computer', 'world']) {
const container = document.createElement('div');
const outcome = await provider.lookup(word, {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
if (outcome.ok) expect(outcome.headword).toBe(word);
expect(container.querySelector('pre')?.textContent?.length ?? 0).toBeGreaterThan(0);
}
});
it('returns empty when the headword is not in the dictionary', async () => {
const provider = createStarDictProvider({ dict: realDict, fs: makeRealFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('zzznonsenseword', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('empty');
});
it('caches the StarDict instance — second lookup opens no extra files', async () => {
const realFs = makeRealFs();
let opens = 0;
const wrappedFs = {
openFile: async (p: string, base: BaseDir) => {
opens += 1;
return realFs.openFile(p, base);
},
};
const provider = createStarDictProvider({ dict: realDict, fs: wrappedFs });
const c1 = document.createElement('div');
await provider.lookup('hello', { signal: new AbortController().signal, container: c1 });
const opensAfterInit = opens;
expect(opensAfterInit).toBe(3); // .ifo, .idx, .dict.dz
const c2 = document.createElement('div');
await provider.lookup('world', { signal: new AbortController().signal, container: c2 });
expect(opens).toBe(opensAfterInit);
});
// -------------------------------------------------------------------------
// Perf regression — bytes read.
//
// Lazy reader contract:
//
// Init reads:
// - .ifo → in full, parsed
// - .idx → in full (scan path) OR not at all (sidecar path);
// bytes are discarded after the offsets array is built
// - .dict.dz → only the gzip+FEXTRA header (~hundreds of bytes) plus
// chunk 0 (~16 KB compressed for cmudict) for the
// streaming-inflate viability probe. NOT the whole file.
//
// Per-lookup reads:
// - .idx → ~log2(N) small Blob slices (~16 B each); LRU-cached.
// - .dict.dz → typically 0 or 1 chunk (one chunk holds many entries);
// LRU-cached. The first lookup whose offset falls in
// chunk 0 reads nothing extra (already loaded by the
// init probe). Other chunks read their compressed size
// (cmudict: ~16 KB, eng-nld: ~8 KB).
//
// These tests guard:
// 1. Init does NOT read the full .dict.dz — only header + one chunk.
// 2. Per-lookup reads from .idx are tiny (≪ file size).
// 3. Per-lookup .dict.dz reads are bounded by the chunk size budget.
// 4. Repeat identical lookups → 0 bytes.
// -------------------------------------------------------------------------
it('init reads .ifo and .idx in full, and only a small slice of .dict.dz', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeRealFs(), counter);
const provider = createStarDictProvider({ dict: realDict, fs });
// Trigger init via a lookup. Using 'aa' lands in the very first .idx
// probe range, minimizing post-init .idx reads.
await provider.lookup('aa', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
const realIfo = await readIfoFile();
const realIdx = await readIdxFile();
const realDictFile = await readDictFile();
expect(counter.perFile.get(IFO_FIXTURE_NAME)).toBe(realIfo.size);
// .idx reads ≥ file size (the init scan needed to build offsets when
// no sidecar is present) + small overhead for first-lookup probes.
const idxRead = counter.perFile.get(IDX_FIXTURE_NAME) ?? 0;
expect(idxRead).toBeGreaterThanOrEqual(realIdx.size);
expect(idxRead).toBeLessThanOrEqual(realIdx.size + 1024);
// .dict.dz is read lazily — header probe + one chunk + maybe one more
// for the lookup. Bound at half the file size; real ratio on cmudict
// is ~22%.
const dictRead = counter.perFile.get(DICT_FIXTURE_NAME) ?? 0;
expect(dictRead).toBeGreaterThan(0);
expect(dictRead).toBeLessThan(realDictFile.size * 0.5);
});
it('per-lookup reads from .idx are bounded (lazy random-access)', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeRealFs(), counter);
const provider = createStarDictProvider({ dict: realDict, fs });
// Prime init with one lookup.
await provider.lookup('hello', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
// Each subsequent lookup should read at most ~log2(105K) × ~16 B ≈
// 300 B from .idx. Bound at 4 KB.
const idxBefore = counter.perFile.get(IDX_FIXTURE_NAME) ?? 0;
let idxLast = idxBefore;
for (const word of ['cat', 'world', 'computer']) {
await provider.lookup(word, {
signal: new AbortController().signal,
container: document.createElement('div'),
});
const idxNow = counter.perFile.get(IDX_FIXTURE_NAME) ?? 0;
const idxDelta = idxNow - idxLast;
expect(idxDelta).toBeLessThan(4 * 1024);
idxLast = idxNow;
}
});
it('per-lookup .dict.dz reads are bounded by the chunk size (lazy decompression)', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeRealFs(), counter);
const provider = createStarDictProvider({ dict: realDict, fs });
// Prime init.
await provider.lookup('hello', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
// Each lookup reads at most one chunk of .dict.dz (typically far less
// when the chunk is already in the LRU). cmudict's chunks compress to
// ~16 KB each; bound at 64 KB to absorb format variance.
let dictLast = counter.perFile.get(DICT_FIXTURE_NAME) ?? 0;
for (const word of ['cat', 'world', 'computer']) {
await provider.lookup(word, {
signal: new AbortController().signal,
container: document.createElement('div'),
});
const dictNow = counter.perFile.get(DICT_FIXTURE_NAME) ?? 0;
const delta = dictNow - dictLast;
expect(delta).toBeLessThanOrEqual(64 * 1024);
dictLast = dictNow;
}
});
it('repeat lookups of the same word warm the LRU → 0 bytes after first', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeRealFs(), counter);
const provider = createStarDictProvider({ dict: realDict, fs });
// Cold lookup (also primes init).
await provider.lookup('hello', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
// Same word again: every .idx probe + .dict.dz chunk hits cache.
const before = counter.total;
await provider.lookup('hello', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
expect(counter.total - before).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Offset sidecar tests.
//
// At import time, `dictionaryService.importStarDictBundle` writes a
// `.idx.offsets` (and optionally `.syn.offsets`) sidecar so subsequent
// provider inits can skip the full `.idx` scan. These tests verify the
// reader honors the sidecar — and falls back gracefully when it's missing.
// ---------------------------------------------------------------------------
import { scanEntryOffsets, serializeOffsetsSidecar } from '@/services/dictionaries/stardictReader';
describe('starDictProvider — offset sidecar', () => {
const buildSidecarFile = async (idxFile: File, name: string): Promise<File> => {
const bytes = new Uint8Array(await idxFile.arrayBuffer());
const offsets = scanEntryOffsets(bytes, /* payloadBytes */ 8);
const sidecar = serializeOffsetsSidecar(offsets);
return new File([new Uint8Array(sidecar)], name);
};
const SIDECAR_NAME = 'cmudict.idx.offsets';
const sidecarDict: ImportedDictionary = {
id: 'stardict:cmudict-with-sidecar',
kind: 'stardict',
name: 'CMU American English spelling',
bundleDir: 'cmudict-bundle',
files: {
ifo: IFO_FIXTURE_NAME,
idx: IDX_FIXTURE_NAME,
dict: DICT_FIXTURE_NAME,
idxOffsets: SIDECAR_NAME,
},
addedAt: 1,
};
const makeFsWithSidecar = () => ({
openFile: async (p: string, _base: BaseDir) => {
const base = p.split('/').pop()!;
if (base === IFO_FIXTURE_NAME) return readIfoFile();
if (base === IDX_FIXTURE_NAME) return readIdxFile();
if (base === DICT_FIXTURE_NAME) return readDictFile();
if (base === SIDECAR_NAME) {
return buildSidecarFile(await readIdxFile(), SIDECAR_NAME);
}
throw new Error(`Unknown fixture file: ${base}`);
},
});
it('with a sidecar, init does NOT read the full .idx file', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeFsWithSidecar(), counter);
const provider = createStarDictProvider({ dict: sidecarDict, fs });
await provider.lookup('aa', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
const realIdx = await readIdxFile();
const idxRead = counter.perFile.get(IDX_FIXTURE_NAME) ?? 0;
// With sidecar, the reader only reads `.idx` for the lookup probes
// — well below the full 1.7 MB scan.
expect(idxRead).toBeLessThan(realIdx.size * 0.05);
expect(idxRead).toBeLessThan(4 * 1024);
// Sanity: lookup still works correctly.
const c = document.createElement('div');
const outcome = await provider.lookup('hello', {
signal: new AbortController().signal,
container: c,
});
expect(outcome.ok).toBe(true);
});
it('with a sidecar, init bytes saved match the .idx file size minus sidecar size', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeFsWithSidecar(), counter);
const provider = createStarDictProvider({ dict: sidecarDict, fs });
await provider.lookup('aa', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
const realIdx = await readIdxFile();
const sidecarFile = await buildSidecarFile(realIdx, SIDECAR_NAME);
const idxRead = counter.perFile.get(IDX_FIXTURE_NAME) ?? 0;
const sidecarRead = counter.perFile.get(SIDECAR_NAME) ?? 0;
// Sidecar consumed in full.
expect(sidecarRead).toBe(sidecarFile.size);
// For cmudict: sidecar is ~422 KB vs `.idx` 1.7 MB → ~75% reduction
// in init reads against `.idx`. Bound generously.
expect(idxRead + sidecarRead).toBeLessThan(realIdx.size);
});
it('falls back to scanning .idx when sidecar bytes are corrupted', async () => {
const corruptedDict: ImportedDictionary = {
...sidecarDict,
id: 'stardict:cmudict-corrupted-sidecar',
};
const fs = {
openFile: async (p: string, _base: BaseDir) => {
const base = p.split('/').pop()!;
if (base === IFO_FIXTURE_NAME) return readIfoFile();
if (base === IDX_FIXTURE_NAME) return readIdxFile();
if (base === DICT_FIXTURE_NAME) return readDictFile();
if (base === SIDECAR_NAME) {
// Garbage bytes — wrong magic.
return new File([new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0, 0, 0, 0])], SIDECAR_NAME);
}
throw new Error(`Unknown fixture file: ${base}`);
},
};
const provider = createStarDictProvider({ dict: corruptedDict, fs });
const c = document.createElement('div');
const outcome = await provider.lookup('hello', {
signal: new AbortController().signal,
container: c,
});
expect(outcome.ok).toBe(true);
});
it('falls back to scanning .idx when sidecar is missing on disk', async () => {
// Simulates a pre-sidecar imported entry where metadata says no sidecar.
const noSidecarDict: ImportedDictionary = {
...sidecarDict,
id: 'stardict:cmudict-no-sidecar',
files: { ...sidecarDict.files, idxOffsets: undefined },
};
const fs = {
openFile: async (p: string, _base: BaseDir) => {
const base = p.split('/').pop()!;
if (base === IFO_FIXTURE_NAME) return readIfoFile();
if (base === IDX_FIXTURE_NAME) return readIdxFile();
if (base === DICT_FIXTURE_NAME) return readDictFile();
throw new Error(`Unknown fixture file: ${base}`);
},
};
const provider = createStarDictProvider({ dict: noSidecarDict, fs });
const c = document.createElement('div');
const outcome = await provider.lookup('hello', {
signal: new AbortController().signal,
container: c,
});
expect(outcome.ok).toBe(true);
});
});
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { wikipediaProvider } from '@/services/dictionaries/providers/wikipediaProvider';
import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
const sampleSummary = {
titles: { display: 'Cat' },
description: 'Small carnivorous mammal',
extract_html: '<p>Cats are small mammals.</p>',
thumbnail: { source: 'https://example/cat.jpg' },
dir: 'ltr',
};
describe('wikipedia provider', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
it('uses the supplied book language as the wiki host prefix', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => sampleSummary,
} as Response);
const container = document.createElement('div');
const controller = new AbortController();
const outcome = await wikipediaProvider.lookup('Cat', {
lang: 'fr',
signal: controller.signal,
container,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const url = fetchMock.mock.calls[0]![0] as string;
expect(url.startsWith('https://fr.wikipedia.org/api/rest_v1/page/summary/')).toBe(true);
expect(outcome.ok).toBe(true);
expect(container.querySelector('h1')?.textContent).toBe('Cat');
expect(container.querySelector('div')?.innerHTML).toContain('Cats are small mammals');
});
it('writes into the supplied container — no document.querySelector', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => sampleSummary,
} as Response);
const stray = document.createElement('main');
document.body.appendChild(stray);
const container = document.createElement('div');
const controller = new AbortController();
await wikipediaProvider.lookup('Cat', {
lang: 'en',
signal: controller.signal,
container,
});
expect(container.children.length).toBeGreaterThan(0);
expect(stray.children.length).toBe(0);
document.body.removeChild(stray);
});
it('falls back to en.wikipedia when no lang is supplied', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => sampleSummary,
} as Response);
const container = document.createElement('div');
const controller = new AbortController();
await wikipediaProvider.lookup('Cat', { signal: controller.signal, container });
const url = fetchMock.mock.calls[0]![0] as string;
expect(url.startsWith('https://en.wikipedia.org/')).toBe(true);
});
it('reports an error outcome on HTTP failure', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 } as Response);
const container = document.createElement('div');
const controller = new AbortController();
const outcome = await wikipediaProvider.lookup('zzz', {
lang: 'en',
signal: controller.signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('error');
});
it('has the expected provider id', () => {
expect(wikipediaProvider.id).toBe(BUILTIN_PROVIDER_IDS.wikipedia);
});
it('renders a "Read on Wikipedia" link to the canonical article', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
...sampleSummary,
content_urls: {
desktop: { page: 'https://en.wikipedia.org/wiki/Cat' },
mobile: { page: 'https://en.m.wikipedia.org/wiki/Cat' },
},
}),
} as Response);
const container = document.createElement('div');
const controller = new AbortController();
await wikipediaProvider.lookup('Cat', {
lang: 'en',
signal: controller.signal,
container,
});
const link = container.querySelector<HTMLAnchorElement>('a[target="_blank"]');
expect(link).toBeTruthy();
expect(link!.href).toBe('https://en.wikipedia.org/wiki/Cat');
expect(link!.rel).toBe('noopener noreferrer');
expect(link!.textContent).toContain('Wikipedia');
});
it('falls back to a constructed article URL when content_urls is missing', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => sampleSummary, // no content_urls
} as Response);
const container = document.createElement('div');
const controller = new AbortController();
await wikipediaProvider.lookup('Cat', {
lang: 'fr',
signal: controller.signal,
container,
});
const link = container.querySelector<HTMLAnchorElement>('a[target="_blank"]');
expect(link).toBeTruthy();
expect(link!.href).toBe('https://fr.wikipedia.org/wiki/Cat');
});
});
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { wiktionaryProvider } from '@/services/dictionaries/providers/wiktionaryProvider';
import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
const sampleResponse = {
en: [
{
partOfSpeech: 'Noun',
language: 'English',
definitions: [
{
definition:
'A <a rel="mw:WikiLink" title="cat" href="/wiki/cat">cat</a> is a small animal.',
examples: ['<i>The cat sat on the mat.</i>'],
},
],
},
],
};
describe('wiktionary provider', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
it('has the expected metadata', () => {
expect(wiktionaryProvider.id).toBe(BUILTIN_PROVIDER_IDS.wiktionary);
expect(wiktionaryProvider.kind).toBe('builtin');
});
it('renders results into the supplied container and reports success', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => sampleResponse,
} as Response);
const container = document.createElement('div');
const controller = new AbortController();
const outcome = await wiktionaryProvider.lookup('cat', {
lang: 'en',
signal: controller.signal,
container,
});
expect(outcome.ok).toBe(true);
if (outcome.ok) {
expect(outcome.headword).toBe('cat');
expect(outcome.sourceLabel).toContain('Wiktionary');
}
expect(container.querySelector('h1')?.textContent).toBe('cat');
expect(container.querySelector('h2')?.textContent).toBe('Noun');
expect(container.querySelector('ol li')?.textContent).toContain('is a small animal');
});
it('rewires WikiLinks to call onNavigate instead of following the href', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => sampleResponse,
} as Response);
const container = document.createElement('div');
const controller = new AbortController();
const onNavigate = vi.fn();
await wiktionaryProvider.lookup('cat', {
lang: 'en',
signal: controller.signal,
container,
onNavigate,
});
const link = container.querySelector<HTMLAnchorElement>('a[rel="mw:WikiLink"]');
expect(link).toBeTruthy();
expect(link!.className).toContain('underline');
link!.click();
expect(onNavigate).toHaveBeenCalledWith('cat');
});
it('returns empty when the API has no entries for the requested language', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({}),
} as Response);
const container = document.createElement('div');
const controller = new AbortController();
const outcome = await wiktionaryProvider.lookup('zzznonsense', {
lang: 'en',
signal: controller.signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('empty');
});
it('returns error on HTTP failure', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 } as Response);
const container = document.createElement('div');
const controller = new AbortController();
const outcome = await wiktionaryProvider.lookup('cat', {
lang: 'en',
signal: controller.signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('error');
});
});
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { MDX, MDD, BlobScanner } from 'js-mdict';
const MDX_PATH = path.resolve(
__dirname,
'../../../../../packages/js-mdict/tests/data/mini/mini.mdx',
);
const MDD_PATH = path.resolve(
__dirname,
'../../../../../packages/js-mdict/tests/data/mini/mini.mdd',
);
describe('js-mdict resolves from readest-app', () => {
it('exports the expected symbols', () => {
expect(typeof MDX).toBe('function');
expect(typeof MDD).toBe('function');
expect(typeof BlobScanner).toBe('function');
});
it('opens an mdx via Blob (lazy slicing)', async () => {
const bytes = await readFile(MDX_PATH);
const file = new Blob([bytes]);
Object.defineProperty(file, 'name', { value: 'mini.mdx' });
const mdx = await MDX.create(file as Blob);
expect(mdx.keywordList.length).toBeGreaterThan(0);
const result = await mdx.lookup('ask');
expect(typeof result.definition).toBe('string');
expect(result.keyText).toBe('ask');
});
it('opens an mdd via Blob and locates a real key', async () => {
const bytes = await readFile(MDD_PATH);
const file = new Blob([bytes]);
Object.defineProperty(file, 'name', { value: 'mini.mdd' });
const mdd = await MDD.create(file as Blob);
expect(mdd.keywordList.length).toBeGreaterThan(0);
const firstKey = mdd.keywordList[0]!.keyText;
const located = await mdd.locate(firstKey);
expect(located.keyText).toBe(firstKey);
});
});
@@ -2,7 +2,6 @@ import { IconType } from 'react-icons';
import { FiSearch } from 'react-icons/fi';
import { FiCopy } from 'react-icons/fi';
import { PiHighlighterFill } from 'react-icons/pi';
import { FaWikipediaW } from 'react-icons/fa';
import { BsPencilSquare } from 'react-icons/bs';
import { BsTranslate } from 'react-icons/bs';
import { TbHexagonLetterD } from 'react-icons/tb';
@@ -70,13 +69,6 @@ export const annotationToolButtons = createAnnotationToolButtons([
Icon: TbHexagonLetterD,
quickAction: true,
},
{
type: 'wikipedia',
label: _('Wikipedia'),
tooltip: _('Look up text in Wikipedia after selection'),
Icon: FaWikipediaW,
quickAction: true,
},
{
type: 'translate',
label: _('Translate'),
@@ -13,6 +13,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useDeviceControlStore } from '@/store/deviceStore';
@@ -41,8 +42,7 @@ import { getHighlightColorHex } from '../../utils/annotatorUtil';
import { annotationToolButtons } from './AnnotationTools';
import AnnotationRangeEditor from './AnnotationRangeEditor';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import DictionaryPopup from './DictionaryPopup';
import TranslatorPopup from './TranslatorPopup';
import useShortcuts from '@/hooks/useShortcuts';
import ProofreadPopup from './ProofreadPopup';
@@ -57,11 +57,19 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
const { setNotebookVisible, setNotebookNewAnnotation } = useNotebookStore();
const { listenToNativeTouchEvents } = useDeviceControlStore();
const { loadCustomDictionaries } = useCustomDictionaryStore();
useNotesSync(bookKey);
useReadwiseSync(bookKey);
useHardcoverSync(bookKey);
useEffect(() => {
void loadCustomDictionaries(envConfig).catch((error) => {
console.warn('Failed to load custom dictionaries:', error);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const osPlatform = getOSPlatform();
const config = getConfig(bookKey)!;
const progress = getProgress(bookKey)!;
@@ -74,8 +82,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [selection, setSelection] = useState<TextSelection | null>(null);
const [showAnnotPopup, setShowAnnotPopup] = useState(false);
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
const [showWikipediaPopup, setShowWikipediaPopup] = useState(false);
const [showDictionaryPopup, setShowDictionaryPopup] = useState(false);
const [showDeepLPopup, setShowDeepLPopup] = useState(false);
const [showProofreadPopup, setShowProofreadPopup] = useState(false);
const [trianglePosition, setTrianglePosition] = useState<Position>();
@@ -107,11 +114,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const deferredQuickActionRef = useRef(createDeferredActionState());
const showingPopup =
showAnnotPopup ||
showWiktionaryPopup ||
showWikipediaPopup ||
showDeepLPopup ||
showProofreadPopup;
showAnnotPopup || showDictionaryPopup || showDeepLPopup || showProofreadPopup;
const popupPadding = useResponsiveSize(10);
const trianglePadding = popupPadding * 2 + 6;
@@ -209,8 +212,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
throttle(() => {
setSelection(null);
setShowAnnotPopup(false);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDictionaryPopup(false);
setShowDeepLPopup(false);
setShowProofreadPopup(false);
setEditingAnnotation(null);
@@ -318,8 +320,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// Show translation popup preferentially for PDF right-click
setShowAnnotPopup(false);
setShowDeepLPopup(true);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDictionaryPopup(false);
}
}
} catch (err) {
@@ -517,9 +518,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
case 'dictionary':
handleDictionary();
break;
case 'wikipedia':
handleWikipedia();
break;
case 'translate':
handleTranslation();
break;
@@ -640,8 +638,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}
setShowAnnotPopup(true);
setShowDeepLPopup(false);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDictionaryPopup(false);
};
const handleCopy = (dismissPopup = true) => {
@@ -772,13 +769,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleDictionary = () => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
setShowWiktionaryPopup(true);
};
const handleWikipedia = () => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
setShowWikipediaPopup(true);
setShowDictionaryPopup(true);
};
const handleTranslation = () => {
@@ -842,9 +833,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onDictionarySelection: () => {
handleDictionary();
},
onWikipediaSelection: () => {
handleWikipedia();
},
onReadAloudSelection: () => {
handleSpeakText();
},
@@ -951,8 +939,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
};
case 'dictionary':
return { tooltipText: _(label), Icon, onClick: handleDictionary };
case 'wikipedia':
return { tooltipText: _(label), Icon, onClick: handleWikipedia };
case 'translate':
return { tooltipText: _(label), Icon, onClick: handleTranslation };
case 'tts':
@@ -975,8 +961,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
return (
<div ref={containerRef} role='toolbar' tabIndex={-1}>
{showWiktionaryPopup && trianglePosition && dictPopupPosition && (
<WiktionaryPopup
{showDictionaryPopup && trianglePosition && dictPopupPosition && (
<DictionaryPopup
word={selection?.text as string}
lang={bookData.bookDoc?.metadata.language as string}
position={dictPopupPosition}
@@ -986,17 +972,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onDismiss={handleDismissPopupAndSelection}
/>
)}
{showWikipediaPopup && trianglePosition && dictPopupPosition && (
<WikipediaPopup
text={selection?.text as string}
lang={bookData.bookDoc?.metadata.language as string}
position={dictPopupPosition}
trianglePosition={trianglePosition}
popupWidth={dictPopupWidth}
popupHeight={dictPopupHeight}
onDismiss={handleDismissPopupAndSelection}
/>
)}
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
<TranslatorPopup
text={selection?.text as string}
@@ -0,0 +1,441 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MdArrowBack } from 'react-icons/md';
import clsx from 'clsx';
import { openUrl } from '@tauri-apps/plugin-opener';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { getEnabledProviders } from '@/services/dictionaries/registry';
import { isTauriAppPlatform } from '@/services/environment';
import type { DictionaryProvider, DictionaryLookupOutcome } from '@/services/dictionaries/types';
const isTauri = isTauriAppPlatform();
interface DictionaryPopupProps {
word: string;
lang?: string;
position: Position;
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
onDismiss?: () => void;
}
interface TabState {
history: { items: string[]; index: number };
loadKey: string;
state: 'idle' | 'loading' | 'loaded' | 'empty' | 'error' | 'unsupported';
outcome?: DictionaryLookupOutcome;
}
const initialTabState = (word: string): TabState => ({
history: { items: [word], index: 0 },
loadKey: '',
state: 'idle',
});
const DictionaryPopup: React.FC<DictionaryPopupProps> = ({
word,
lang,
position,
trianglePosition,
popupWidth,
popupHeight,
onDismiss,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { dictionaries, settings, setDefaultProviderId, saveCustomDictionaries } =
useCustomDictionaryStore();
// Compute the enabled-provider list, then memoize by the resulting ID
// signature so unrelated settings tweaks (e.g. `setDefaultProviderId`
// saving the last-used tab) don't change the array reference and trigger
// a spurious lookup-effect re-fire mid-init.
const computedProviders = getEnabledProviders({
settings,
dictionaries,
fs: appService ?? undefined,
});
const providersSignature = computedProviders.map((p) => p.id).join('|');
// eslint-disable-next-line react-hooks/exhaustive-deps
const providers = useMemo<DictionaryProvider[]>(() => computedProviders, [providersSignature]);
const fallbackTabId = providers[0]?.id;
const initialTab = useMemo(() => {
if (!providers.length) return undefined;
if (settings.defaultProviderId && providers.some((p) => p.id === settings.defaultProviderId)) {
return settings.defaultProviderId;
}
return fallbackTabId;
}, [providers, settings.defaultProviderId, fallbackTabId]);
const [activeTab, setActiveTab] = useState<string | undefined>(initialTab);
const [tabStates, setTabStates] = useState<Record<string, TabState>>(() => {
const seed: Record<string, TabState> = {};
providers.forEach((p) => {
seed[p.id] = initialTabState(word);
});
return seed;
});
// Reset all tabs when the looked-up word changes from outside.
useEffect(() => {
setTabStates((prev) => {
const next: Record<string, TabState> = {};
for (const provider of providers) {
const old = prev[provider.id];
if (old && old.history.items[0] === word && old.history.index === 0) {
next[provider.id] = old;
} else {
next[provider.id] = initialTabState(word);
}
}
return next;
});
}, [word, providers]);
// If the persisted defaultProviderId disappears (provider disabled / removed),
// fall back to the first available tab.
useEffect(() => {
if (!providers.length) {
if (activeTab !== undefined) setActiveTab(undefined);
return;
}
if (!activeTab || !providers.some((p) => p.id === activeTab)) {
setActiveTab(fallbackTabId);
}
}, [providers, activeTab, fallbackTabId]);
// Persist last-active tab as the user switches.
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!activeTab) return;
if (settings.defaultProviderId === activeTab) return;
setDefaultProviderId(activeTab);
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
persistTimerRef.current = setTimeout(() => {
void saveCustomDictionaries(envConfig).catch(() => {});
}, 500);
return () => {
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}
};
}, [
activeTab,
settings.defaultProviderId,
setDefaultProviderId,
saveCustomDictionaries,
envConfig,
]);
// Per-tab DOM container refs. Providers render into these.
const containerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const setContainerRef = useCallback(
(id: string) => (el: HTMLDivElement | null) => {
if (el) containerRefs.current.set(id, el);
else containerRefs.current.delete(id);
},
[],
);
/**
* Click delegation for provider-rendered anchors.
*
* Providers (Wikipedia "Read on Wikipedia →", error placeholders, etc.)
* append `<a>` elements imperatively to `ctx.container`. Those elements
* can't use the React `Link` component, so route external http(s)
* clicks through Tauri's `openUrl` here. Internal clicks (relative
* `/wiki/...` links from Wiktionary, intercepted by the provider for
* in-popup history) keep their existing behaviour we only act when
* the raw `href` attribute starts with `http(s)://`.
*/
const handleContainerClick = useCallback((e: React.MouseEvent) => {
if (!isTauri) return; // Non-Tauri: target="_blank" + rel handles it.
if (e.defaultPrevented) return; // Provider already handled it.
const anchor = (e.target as Element | null)?.closest?.('a');
if (!anchor) return;
const rawHref = anchor.getAttribute('href');
if (!rawHref || !/^https?:\/\//i.test(rawHref)) return;
e.preventDefault();
void openUrl(rawHref).catch((err) => {
console.warn('Failed to open external URL', rawHref, err);
});
}, []);
const pushHistory = useCallback((tabId: string, nextWord: string) => {
const trimmed = nextWord.trim();
if (!trimmed) return;
setTabStates((prev) => {
const old = prev[tabId];
if (!old) return prev;
const currentWord = old.history.items[old.history.index];
if (currentWord === trimmed) return prev;
const items = [...old.history.items.slice(0, old.history.index + 1), trimmed];
return {
...prev,
[tabId]: { ...old, history: { items, index: items.length - 1 } },
};
});
}, []);
const goBack = useCallback((tabId: string) => {
setTabStates((prev) => {
const old = prev[tabId];
if (!old || old.history.index === 0) return prev;
return {
...prev,
[tabId]: { ...old, history: { ...old.history, index: old.history.index - 1 } },
};
});
}, []);
const activeTabState = activeTab ? tabStates[activeTab] : undefined;
const lookupIndex = activeTabState?.history.index ?? 0;
const lookupWord = activeTabState?.history.items[lookupIndex] ?? word;
const lookupLoadKey = activeTabState?.loadKey ?? '';
const lookupState = activeTabState?.state;
// Lookup effect — runs whenever the active tab's lookupWord changes (initial
// activation, history navigation, or word prop changes).
useEffect(() => {
if (!activeTab) return;
if (!activeTabState) return;
const provider = providers.find((p) => p.id === activeTab);
if (!provider) return;
const langCode = typeof lang === 'string' ? lang : Array.isArray(lang) ? lang[0] : undefined;
const loadKey = `${lookupWord}::${langCode || ''}`;
// Skip only when we already have a settled outcome for this loadKey.
// We must NOT skip on `state==='loading'`: a previous effect cleanup
// may have aborted the in-flight run before it could update state, in
// which case the next fire is the only chance to actually load the
// result. Skipping here would deadlock the tab on the spinner.
const isSettled =
lookupState === 'loaded' ||
lookupState === 'empty' ||
lookupState === 'error' ||
lookupState === 'unsupported';
if (lookupLoadKey === loadKey && isSettled) return;
const container = containerRefs.current.get(activeTab);
if (!container) return;
container.replaceChildren();
container.scrollTop = 0;
const controller = new AbortController();
setTabStates((prev) => {
const old = prev[activeTab];
if (!old) return prev;
return { ...prev, [activeTab]: { ...old, loadKey, state: 'loading' } };
});
let cancelled = false;
const run = async () => {
let outcome: DictionaryLookupOutcome;
try {
if (provider.init) await provider.init();
outcome = await provider.lookup(lookupWord, {
lang: langCode,
signal: controller.signal,
container,
onNavigate: (next) => pushHistory(activeTab, next),
});
} catch (error) {
outcome = {
ok: false,
reason: 'error',
message: error instanceof Error ? error.message : String(error),
};
}
if (cancelled || controller.signal.aborted) return;
if (!outcome.ok && container.childElementCount === 0) {
renderErrorPlaceholder(container, lookupWord, outcome, _);
}
const state = outcome.ok
? 'loaded'
: outcome.reason === 'empty'
? 'empty'
: outcome.reason === 'unsupported'
? 'unsupported'
: 'error';
setTabStates((prev) => {
const old = prev[activeTab];
if (!old || old.loadKey !== loadKey) return prev;
return { ...prev, [activeTab]: { ...old, state, outcome } };
});
};
void run();
return () => {
cancelled = true;
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, providers, lookupWord, lang, lookupIndex]);
const canGoBack = !!activeTabState && activeTabState.history.index > 0;
const sourceLabel =
activeTabState?.outcome?.ok && activeTabState.outcome.sourceLabel
? activeTabState.outcome.sourceLabel
: undefined;
return (
<Popup
width={popupWidth}
height={popupHeight}
position={position}
trianglePosition={trianglePosition}
className='select-text'
onDismiss={onDismiss}
>
<div className='relative flex h-full flex-col'>
{providers.length > 1 && (
<div
role='tablist'
className='tabs tabs-bordered border-base-content/10 not-eink:bg-base-300/40 flex shrink-0 border-b'
>
{providers.map((p) => {
const isActive = p.id === activeTab;
return (
<button
key={p.id}
type='button'
role='tab'
aria-selected={isActive}
onClick={() => setActiveTab(p.id)}
title={_(p.label)}
className={clsx(
'tab !grid min-w-0 max-w-max flex-1 px-2 text-sm',
isActive
? 'tab-active text-base-content'
: 'text-base-content/70 hover:text-base-content',
)}
>
{/* Phantom: invisible, always bold. Defines the cell's
max-content width so it doesn't change with active state. */}
<span
aria-hidden
className='invisible col-start-1 row-start-1 w-full truncate text-left font-semibold'
>
{_(p.label)}
</span>
{/* Visible label stacked over the phantom in the same cell. */}
<span
className={clsx(
'col-start-1 row-start-1 w-full truncate text-left',
isActive && 'font-semibold',
)}
>
{_(p.label)}
</span>
</button>
);
})}
</div>
)}
{providers.length === 0 ? (
<div className='flex h-full flex-col items-center justify-center px-6 text-center'>
<h1 className='text-base font-bold'>{_('No dictionaries enabled')}</h1>
<p className='not-eink:opacity-70 mt-2 text-sm'>
{_('Enable a dictionary in Settings → Language → Dictionaries.')}
</p>
</div>
) : (
providers.map((p) => {
const isActive = p.id === activeTab;
const state = tabStates[p.id]?.state ?? 'idle';
const showBack = isActive && canGoBack;
return (
<div
key={p.id}
role='tabpanel'
hidden={!isActive}
className={clsx('relative min-h-0 flex-1', isActive ? 'flex flex-col' : 'hidden')}
>
{showBack && (
<button
type='button'
onClick={() => goBack(p.id)}
aria-label={_('Back')}
className='btn btn-ghost btn-circle text-base-content bg-base-200/80 hover:bg-base-200 absolute left-2 top-2 z-10 h-8 min-h-8 w-8 p-0 shadow-sm'
>
<MdArrowBack size={18} />
</button>
)}
<div
ref={setContainerRef(p.id)}
data-state={state}
onClick={handleContainerClick}
className='flex-grow overflow-y-auto px-4 pb-4 font-sans'
style={{ paddingTop: showBack ? 48 : 16 }}
/>
{isActive && state === 'loading' && (
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<span className='loading loading-spinner loading-md not-eink:opacity-60' />
</div>
)}
</div>
);
})
)}
{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>
)}
</div>
</Popup>
);
};
const renderErrorPlaceholder = (
container: HTMLElement,
word: string,
outcome: DictionaryLookupOutcome,
_: (key: string, opts?: Record<string, string | number>) => string,
): void => {
const wrapper = document.createElement('div');
wrapper.className =
'flex flex-col items-center justify-center w-full h-full text-center absolute inset-0 px-6';
const h1 = document.createElement('h1');
h1.className = 'text-base font-bold';
const p = document.createElement('p');
p.className = 'mt-2 text-sm not-eink:opacity-75';
if (!outcome.ok && outcome.reason === 'empty') {
h1.innerText = _('No definitions found');
// Skip target="_blank" on Tauri — see the comment in
// `wikipediaProvider.ts`. The popup's container click handler routes
// the click through `openUrl` for Tauri.
const targetAttr = isTauri ? '' : ' target="_blank"';
p.innerHTML = _('Search for {{word}} on the web.', {
word: `<a href="https://www.google.com/search?q=${encodeURIComponent(
word,
)}"${targetAttr} rel="noopener noreferrer" class="not-eink:text-primary underline">${word}</a>`,
});
} else if (!outcome.ok && outcome.reason === 'unsupported') {
h1.innerText = _('Dictionary unsupported');
p.innerText = outcome.message ?? _('This dictionary format is not supported yet.');
} else {
h1.innerText = _('Error');
p.innerText = (!outcome.ok && outcome.message) || _('Unable to load the word.');
}
wrapper.append(h1, p);
container.append(wrapper);
};
export default DictionaryPopup;
@@ -1,133 +0,0 @@
import React, { useEffect, useRef } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { useTranslation } from '@/hooks/useTranslation';
interface WikipediaPopupProps {
text: string;
lang: string;
position: Position;
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
onDismiss?: () => void;
}
const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
text,
lang,
position,
trianglePosition,
popupWidth,
popupHeight,
onDismiss,
}) => {
const _ = useTranslation();
const isLoading = useRef(false);
useEffect(() => {
if (isLoading.current) {
return;
}
isLoading.current = true;
const main = document.querySelector('main') as HTMLElement;
const footer = document.querySelector('footer') as HTMLElement;
const fetchSummary = async (query: string, language: string) => {
main.innerHTML = '';
footer.dataset['state'] = 'loading';
try {
const response = await fetch(
`https://${language}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(query)}`,
);
if (!response.ok) {
throw new Error('Failed to fetch Wikipedia summary');
}
const data = await response.json();
const hgroup = document.createElement('hgroup');
hgroup.style.color = 'white';
hgroup.style.backgroundPosition = 'center center';
hgroup.style.backgroundSize = 'cover';
hgroup.style.backgroundColor = 'rgba(0, 0, 0, .4)';
hgroup.style.backgroundBlendMode = 'darken';
hgroup.style.borderRadius = '6px';
hgroup.style.padding = '12px';
hgroup.style.marginBottom = '12px';
hgroup.style.minHeight = '100px';
const h1 = document.createElement('h1');
h1.innerHTML = data.titles.display;
h1.className = 'text-lg font-bold';
hgroup.append(h1);
if (data.description) {
const description = document.createElement('p');
description.innerText = data.description;
hgroup.appendChild(description);
}
if (data.thumbnail) {
hgroup.style.backgroundImage = `url("${data.thumbnail.source}")`;
}
const contentDiv = document.createElement('div');
contentDiv.innerHTML = data.extract_html;
contentDiv.className = 'p-2 text-sm';
contentDiv.dir = data.dir;
main.append(hgroup, contentDiv);
footer.dataset['state'] = 'loaded';
} catch (error) {
console.error(error);
const errorDiv = document.createElement('div');
const h1 = document.createElement('h1');
h1.innerText = _('Error');
const errorMsg = document.createElement('p');
errorMsg.innerHTML = _('Unable to load the article. Try searching directly on {{link}}.', {
link: `<a href="https://${language}.wikipedia.org/w/index.php?search=${encodeURIComponent(
query,
)}" target="_blank" rel="noopener noreferrer" class="not-eink:text-primary underline">Wikipedia</a>`,
});
errorDiv.append(h1, errorMsg);
main.appendChild(errorDiv);
footer.dataset['state'] = 'error';
}
};
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
const langCode = bookLang ? bookLang.split('-')[0]! : 'en';
fetchSummary(text, langCode);
}, [_, text, lang]);
return (
<div>
<Popup
width={popupWidth}
height={popupHeight}
position={position}
trianglePosition={trianglePosition}
className='select-text'
onDismiss={onDismiss}
>
<div className='text-base-content flex h-full flex-col pt-2'>
<main className='flex-grow overflow-y-auto px-2 font-sans'></main>
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
<div className='not-eink:opacity-60 flex items-center px-4 py-2 text-sm'>
Source: Wikipedia (CC BY-SA)
</div>
</footer>
</div>
</Popup>
</div>
);
};
export default WikipediaPopup;
@@ -1,382 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { MdArrowBack } from 'react-icons/md';
import { Position } from '@/utils/sel';
import { useTranslation } from '@/hooks/useTranslation';
import { normalizedLangCode } from '@/utils/lang';
import { fetchChineseDefinition } from '@/services/dictionaries/chineseDict';
import Popup from '@/components/Popup';
type Definition = {
definition: string;
examples?: string[];
};
type Result = {
partOfSpeech: string;
definitions: Definition[];
language: string;
};
interface WiktionaryPopupProps {
word: string;
lang?: string;
position: Position;
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
onDismiss?: () => void;
}
const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
word,
lang,
position,
trianglePosition,
popupWidth,
popupHeight,
onDismiss,
}) => {
const _ = useTranslation();
const [history, setHistory] = useState<{ items: string[]; index: number }>({
items: [word],
index: 0,
});
const lastLookupRef = useRef('');
const mainRef = useRef<HTMLElement | null>(null);
const footerRef = useRef<HTMLElement | null>(null);
const lastScrollTopRef = useRef(0);
const lastDirectionRef = useRef<'up' | 'down' | null>(null);
const scrollDeltaRef = useRef(0);
const rafRef = useRef<number | null>(null);
const [isBackVisible, setIsBackVisible] = useState(false);
const lookupWord = history.items[history.index] ?? word;
const canGoBack = history.index > 0;
const showBackButton = canGoBack && isBackVisible;
useEffect(() => {
setHistory({ items: [word], index: 0 });
}, [word]);
useEffect(() => {
if (!canGoBack) {
setIsBackVisible(false);
lastScrollTopRef.current = 0;
lastDirectionRef.current = null;
scrollDeltaRef.current = 0;
return;
}
setIsBackVisible(true);
}, [canGoBack]);
useEffect(() => {
if (!canGoBack) return;
const main = mainRef.current;
if (!main) return;
const handleScroll = () => {
if (rafRef.current !== null) return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
const currentScrollTop = main.scrollTop;
const delta = currentScrollTop - lastScrollTopRef.current;
if (delta === 0) return;
if (currentScrollTop <= 4) {
setIsBackVisible(true);
lastDirectionRef.current = null;
scrollDeltaRef.current = 0;
lastScrollTopRef.current = currentScrollTop;
return;
}
const direction: 'up' | 'down' = delta > 0 ? 'down' : 'up';
if (direction !== lastDirectionRef.current) {
lastDirectionRef.current = direction;
scrollDeltaRef.current = 0;
}
scrollDeltaRef.current += Math.abs(delta);
const hideThreshold = 14;
const showThreshold = 8;
if (direction === 'down' && scrollDeltaRef.current >= hideThreshold) {
setIsBackVisible(false);
scrollDeltaRef.current = 0;
} else if (direction === 'up' && scrollDeltaRef.current >= showThreshold) {
setIsBackVisible(true);
scrollDeltaRef.current = 0;
}
lastScrollTopRef.current = currentScrollTop;
});
};
lastScrollTopRef.current = main.scrollTop;
main.addEventListener('scroll', handleScroll, { passive: true });
return () => {
main.removeEventListener('scroll', handleScroll);
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [canGoBack]);
useEffect(() => {
setIsBackVisible(true);
lastScrollTopRef.current = 0;
lastDirectionRef.current = null;
scrollDeltaRef.current = 0;
if (mainRef.current) {
mainRef.current.scrollTop = 0;
}
}, [lookupWord]);
const pushHistory = (nextWord: string) => {
const trimmedWord = nextWord.trim();
if (!trimmedWord) return;
setHistory((prev) => {
const currentWord = prev.items[prev.index];
if (currentWord === trimmedWord) return prev;
const items = [...prev.items.slice(0, prev.index + 1), trimmedWord];
return { items, index: items.length - 1 };
});
};
const handleBack = () => {
setHistory((prev) => {
if (prev.index === 0) return prev;
return { ...prev, index: prev.index - 1 };
});
};
const interceptDictLinks = (definition: string): HTMLElement[] => {
const container = document.createElement('div');
container.innerHTML = definition;
const links = container.querySelectorAll<HTMLAnchorElement>('a[rel="mw:WikiLink"]');
links.forEach((link) => {
const title = link.getAttribute('title');
if (title) {
link.addEventListener('click', (event) => {
event.preventDefault();
pushHistory(title);
});
link.className = 'not-eink:text-primary underline cursor-pointer';
}
});
return Array.from(container.childNodes) as HTMLElement[];
};
useEffect(() => {
const langCode = typeof lang === 'string' ? lang : lang?.[0];
const lookupKey = `${lookupWord}::${langCode || ''}`;
if (lastLookupRef.current === lookupKey) return;
lastLookupRef.current = lookupKey;
const main = mainRef.current;
const footer = footerRef.current;
if (!main || !footer) return;
const renderError = (word: string) => {
footer.dataset['state'] = 'error';
const div = document.createElement('div');
div.className =
'flex flex-col items-center justify-center w-full h-full text-center absolute inset-0';
const h1 = document.createElement('h1');
h1.innerText = _('Error');
h1.className = 'text-lg font-bold';
const p = document.createElement('p');
p.innerHTML = _('Unable to load the word. Try searching directly on {{link}}.', {
link: `<a href="https://en.wiktionary.org/w/index.php?search=${encodeURIComponent(
word,
)}" target="_blank" rel="noopener noreferrer" class="not-eink:text-primary underline">Wiktionary</a>`,
});
div.append(h1, p);
main.append(div);
};
const fetchChineseDefs = async (word: string) => {
const entry = await fetchChineseDefinition(word);
if (!entry) throw new Error('No Chinese entry found');
const hgroup = document.createElement('hgroup');
const h1 = document.createElement('h1');
h1.innerText = entry.word;
h1.className = 'text-lg font-bold';
hgroup.append(h1);
if (entry.pinyin) {
const pinyinEl = document.createElement('p');
pinyinEl.innerText = entry.pinyin;
pinyinEl.className = 'text-base italic not-eink:opacity-85';
hgroup.append(pinyinEl);
}
const langEl = document.createElement('p');
langEl.innerText = 'Chinese';
langEl.className = 'text-sm italic not-eink:opacity-75';
hgroup.append(langEl);
main.append(hgroup);
entry.definitions.forEach(({ partOfSpeech, meanings }) => {
const h2 = document.createElement('h2');
h2.innerText = partOfSpeech;
h2.className = 'text-base font-semibold mt-4';
const ol = document.createElement('ol');
ol.className = 'pl-8 list-decimal';
meanings.forEach((meaning) => {
const li = document.createElement('li');
li.innerText = meaning;
ol.appendChild(li);
});
main.appendChild(h2);
main.appendChild(ol);
});
footer.dataset['state'] = 'loaded';
};
const fetchWiktionaryDefs = async (word: string, language?: string) => {
const response = await fetch(
`https://en.wiktionary.org/api/rest_v1/page/definition/${encodeURIComponent(word)}`,
);
if (!response.ok) {
throw new Error('Failed to fetch definitions');
}
const json = await response.json();
const results: Result[] | undefined = language
? json[language] || json['en']
: json[Object.keys(json)[0]!];
if (!results || results.length === 0) {
throw new Error('No results found');
}
const hgroup = document.createElement('hgroup');
const h1 = document.createElement('h1');
h1.innerText = word;
h1.className = 'text-lg font-bold';
const p = document.createElement('p');
p.innerText = results[0]!.language;
p.className = 'text-sm italic not-eink:opacity-75';
hgroup.append(h1, p);
main.append(hgroup);
results.forEach(({ partOfSpeech, definitions }: Result) => {
const h2 = document.createElement('h2');
h2.innerText = partOfSpeech;
h2.className = 'text-base font-semibold mt-4';
const ol = document.createElement('ol');
ol.className = 'pl-8 list-decimal';
definitions.forEach(({ definition, examples }: Definition) => {
if (!definition) return;
const li = document.createElement('li');
const processedContent = interceptDictLinks(definition);
li.append(...processedContent);
if (examples) {
const ul = document.createElement('ul');
ul.className = 'pl-8 list-disc text-sm italic not-eink:opacity-75';
examples.forEach((example) => {
const exampleLi = document.createElement('li');
exampleLi.innerHTML = example;
ul.appendChild(exampleLi);
});
li.appendChild(ul);
}
ol.appendChild(li);
});
main.appendChild(h2);
main.appendChild(ol);
});
footer.dataset['state'] = 'loaded';
};
const fetchDefinitions = async (word: string, language?: string) => {
main.innerHTML = '';
footer.dataset['state'] = 'loading';
try {
const isChineseLookup = language && normalizedLangCode(language) === 'zh';
if (isChineseLookup) {
await fetchChineseDefs(word);
} else {
await fetchWiktionaryDefs(word, language);
}
} catch (error) {
console.error(error);
renderError(word);
}
};
fetchDefinitions(lookupWord, langCode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [_, lookupWord, lang]);
return (
<div>
<Popup
trianglePosition={trianglePosition}
width={popupWidth}
height={popupHeight}
position={position}
className='select-text'
onDismiss={onDismiss}
>
<div className='relative flex h-full flex-col'>
{canGoBack && (
<button
type='button'
onClick={handleBack}
aria-label={_('Back')}
className={`btn btn-ghost btn-circle text-base-content bg-base-200/80 hover:bg-base-200 absolute left-2 top-2 h-8 min-h-8 w-8 p-0 shadow-sm transition-[opacity,transform] duration-200 ease-out ${
showBackButton
? 'translate-y-0 opacity-100'
: 'pointer-events-none -translate-y-1 opacity-0'
}`}
>
<MdArrowBack size={18} />
</button>
)}
<main
ref={mainRef}
className='flex-grow overflow-y-auto px-4 pb-4 font-sans'
style={{
paddingTop: showBackButton ? 48 : 16,
transition: 'padding-top 180ms ease-out',
}}
/>
<footer
ref={footerRef}
className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'
>
<div className='not-eink:opacity-60 flex items-center px-4 py-2 text-sm'>
Source: Wiktionary (CC BY-SA)
</div>
</footer>
</div>
</Popup>
</div>
);
};
export default WiktionaryPopup;
@@ -0,0 +1,394 @@
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 } from '@/services/dictionaries/types';
interface CustomDictionariesProps {
onBack: () => void;
}
interface ProviderRow {
id: string;
label: string;
kind: 'builtin' | 'stardict' | 'mdict';
badge: string;
imported?: ImportedDictionary;
disabled?: boolean;
reason?: string;
}
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;
_: (key: string, options?: Record<string, number | string>) => string;
}
const SortableRow: React.FC<SortableRowProps> = ({
row,
enabled,
isDeleteMode,
onToggle,
onDelete,
_,
}) => {
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',
isDragging && 'bg-base-200 z-10 shadow-md',
)}
>
{/* 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>
<span className='badge badge-sm badge-ghost shrink-0'>{row.badge}</span>
</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>
<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 only for imported rows, only in delete mode. Built-ins
never show it; imported rows reserve no width when not in delete
mode so their toggle aligns with the built-ins'. */}
{row.imported && 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,
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);
const buildRows = (): ProviderRow[] => {
const dictById = new Map(dictionaries.map((d) => [d.id, d]));
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;
}
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') : _('StarDict'),
imported: dict,
disabled,
reason,
});
}
return rows;
};
const rows = buildRows();
const hasImported = rows.some((r) => r.imported);
// 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) return;
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);
await saveCustomDictionaries(envConfig);
// Auto-leave delete mode when the last imported entry is gone — there's
// nothing left to delete.
if (rows.filter((r) => r.imported).length <= 1) {
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>
{hasImported && (
<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>
<div className='card border-primary/50 hover:border-primary/75 group mb-4 border-2 transition-colors'>
<button
type='button'
className='card-body flex cursor-pointer items-center justify-center p-3 text-center'
onClick={handleImport}
disabled={importing}
>
<div className='flex items-center gap-2'>
<MdAdd className='text-primary/85 group-hover:text-primary h-6 w-6' />
<div className='text-primary/85 group-hover:text-primary line-clamp-1 font-medium'>
{importing ? _('Importing…') : _('Import Dictionary')}
</div>
</div>
</button>
</div>
<div className='card border-base-200 bg-base-100 border shadow'>
<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}
_={_}
/>
))}
</SortableContext>
</DndContext>
</div>
</div>
<div className='bg-base-200/30 my-6 rounded-lg p-4'>
<div className='text-base-content/70 text-sm sm:text-xs'>
<div className='mb-1 indent-2 font-medium'>{_('Tips')}:</div>
<ul className='list-outside list-disc space-y-1 ps-2'>
<li>{_('Drag the handle on the left to reorder.')}</li>
<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>{_('Select all the bundle files together when importing.')}</li>
</ul>
</div>
</div>
</div>
);
};
export default CustomDictionaries;
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { MdChevronRight } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useReaderStore } from '@/store/readerStore';
@@ -18,6 +19,7 @@ import { SettingsPanelPanelProp } from './SettingsDialog';
import { getDirFromLanguage } from '@/utils/rtl';
import { isCJKEnv } from '@/utils/misc';
import Select from '@/components/Select';
import CustomDictionaries from './CustomDictionaries';
const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
@@ -40,6 +42,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const [convertChineseVariant, setConvertChineseVariant] = useState(
viewSettings.convertChineseVariant,
);
const [showCustomDictionaries, setShowCustomDictionaries] = useState(false);
const resetToDefaults = useResetViewSettings();
@@ -242,6 +245,14 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [convertChineseVariant]);
if (showCustomDictionaries) {
return (
<div className='my-4 w-full'>
<CustomDictionaries onBack={() => setShowCustomDictionaries(false)} />
</div>
);
}
return (
<div className={clsx('my-4 w-full space-y-6')}>
<div className='w-full' data-setting-id='settings.language.interfaceLanguage'>
@@ -260,6 +271,22 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
</div>
</div>
<div className='w-full' data-setting-id='settings.language.dictionaries'>
<h2 className='mb-2 font-medium'>{_('Dictionaries')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<button
type='button'
className='config-item hover:bg-base-200/40 w-full text-left'
onClick={() => setShowCustomDictionaries(true)}
>
<span>{_('Manage Dictionaries')}</span>
<MdChevronRight className='text-base-content/60 h-5 w-5' />
</button>
</div>
</div>
</div>
<div className='w-full' data-setting-id='settings.language.translationEnabled'>
<h2 className='mb-2 font-medium'>{_('Translation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
@@ -118,11 +118,6 @@ const DEFAULT_SHORTCUTS = {
description: _('Dictionary Lookup'),
section: 'Selection',
},
onWikipediaSelection: {
keys: ['ctrl+w', 'cmd+w'],
description: _('Wikipedia Lookup'),
section: 'Selection',
},
onReadAloudSelection: {
keys: ['ctrl+r', 'cmd+r'],
description: _('Read Aloud Selection'),
@@ -139,6 +139,11 @@ export const FILE_SELECTION_PRESETS = {
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'],
@@ -21,9 +21,12 @@ import { getOSPlatform } from '@/utils/misc';
import { ProgressHandler } from '@/utils/transfer';
import { CustomTextureInfo } from '@/styles/textures';
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
import type { ImportedDictionary } from './dictionaries/types';
import type { SelectedFile } from '@/hooks/useFileSelector';
import * as BookSvc from './bookService';
import * as CloudSvc from './cloudService';
import * as DictSvc from './dictionaries/dictionaryService';
import * as FontSvc from './fontService';
import * as ImageSvc from './imageService';
import * as LibrarySvc from './libraryService';
@@ -219,6 +222,14 @@ export abstract class BaseAppService implements AppService {
return ImageSvc.deleteImage(this.fs, texture);
}
async importDictionaries(files: SelectedFile[]): Promise<DictSvc.ImportDictionariesResult> {
return DictSvc.importDictionaries(this.fs, files);
}
async deleteDictionary(dict: ImportedDictionary): Promise<void> {
return DictSvc.deleteDictionary(this.fs, dict);
}
async importBook(
file: string | File,
books: Book[],
@@ -35,6 +35,7 @@ export const LOCAL_BOOKS_SUBDIR = `${DATA_SUBDIR}/Books`;
export const CLOUD_BOOKS_SUBDIR = `${DATA_SUBDIR}/Books`;
export const LOCAL_FONTS_SUBDIR = `${DATA_SUBDIR}/Fonts`;
export const LOCAL_IMAGES_SUBDIR = `${DATA_SUBDIR}/Images`;
export const LOCAL_DICTIONARIES_SUBDIR = `${DATA_SUBDIR}/Dictionaries`;
export const SETTINGS_FILENAME = 'settings.json';
@@ -109,6 +110,15 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
metadataOthersCollapsed: false,
metadataDescriptionCollapsed: false,
customDictionaries: [],
dictionarySettings: {
providerOrder: ['builtin:wiktionary', 'builtin:wikipedia'],
providerEnabled: {
'builtin:wiktionary': true,
'builtin:wikipedia': true,
},
},
kosync: DEFAULT_KOSYNC_SETTINGS,
readwise: DEFAULT_READWISE_SETTINGS,
hardcover: DEFAULT_HARDCOVER_SETTINGS,
@@ -0,0 +1,348 @@
/**
* Dictionary import / delete service.
*
* Takes a flat list of files chosen via `useFileSelector('dictionaries')`,
* groups them into StarDict / MDict bundles by filename stem, writes each
* bundle's files into `'Dictionaries'/<id>/`, and returns metadata records
* for the {@link customDictionaryStore}.
*
* Mirrors the structure of `src/services/fontService.ts` but handles
* multi-file bundles instead of single-file fonts.
*/
import type { FileSystem } from '@/types/system';
import type { SelectedFile } from '@/hooks/useFileSelector';
import { uniqueId } from '@/utils/misc';
import { getFilename } from '@/utils/path';
import type { ImportedDictionary } from './types';
import { scanEntryOffsets, serializeOffsetsSidecar } from './stardictReader';
/** GZIP magic bytes — used to detect DictZip-compressed `.dict` files. */
const GZIP_MAGIC = [0x1f, 0x8b, 0x08];
interface SourceFile {
/** Filename including extension, e.g. `oald7.idx`. */
name: string;
/** Stem (lowercase, without final extension). For `.dict.dz` use the stem of `.dict`. */
stem: string;
/** Final extension (lowercase, no dot). For `oald7.dict.dz`, this is `dz`. */
ext: string;
/** True when ext is `dz` AND the next-to-last segment is `dict`. */
isDictZip: boolean;
/** Raw input — Tauri path or browser File. */
source: SelectedFile;
}
interface StarDictGroup {
kind: 'stardict';
stem: string;
ifo: SourceFile;
idx: SourceFile;
dict: SourceFile;
syn?: SourceFile;
}
interface MDictGroup {
kind: 'mdict';
stem: string;
mdx: SourceFile;
mdd: SourceFile[];
}
type Bundle = StarDictGroup | MDictGroup;
interface GroupResult {
bundles: Bundle[];
/** Files that didn't form a complete bundle (e.g. `.idx` without matching `.ifo`). */
orphans: SourceFile[];
}
/** Read the source file as a `File` (web) or via the path (Tauri filesystem). */
async function readSource(fs: FileSystem, source: SelectedFile): Promise<File> {
if (source.file) return source.file;
if (source.path) {
// Open from absolute filesystem path. `'None'` keeps the path as-is.
return fs.openFile(source.path, 'None');
}
throw new Error('SelectedFile has neither path nor file');
}
function classify(source: SelectedFile): SourceFile {
const rawName = source.file?.name ?? (source.path ? getFilename(source.path) : '');
const name = rawName;
const lower = name.toLowerCase();
const lastDot = lower.lastIndexOf('.');
const ext = lastDot >= 0 ? lower.slice(lastDot + 1) : '';
// Detect `foo.dict.dz` — final ext is `dz`, but the bundle stem is `foo`.
const beforeLast = lastDot >= 0 ? lower.slice(0, lastDot) : lower;
const isDictZip = ext === 'dz' && beforeLast.endsWith('.dict');
let stem: string;
if (isDictZip) {
// `foo.dict.dz` → stem `foo`
stem = beforeLast.slice(0, -'.dict'.length);
} else if (lastDot >= 0) {
// `foo.idx` → stem `foo`
stem = beforeLast;
} else {
stem = lower;
}
return { name, stem, ext, isDictZip, source };
}
/**
* Group a flat list of selected files into StarDict and MDict bundles by
* stem. Files that don't belong to any complete bundle land in `orphans`.
*
* Rules:
* - StarDict bundle = exactly one `.ifo` + one `.idx` + one `.dict` or
* `.dict.dz` (sharing a stem). `.syn` is optional.
* - MDict bundle = one `.mdx` + zero or more `.mdd` (sharing a stem).
* - A stem with both StarDict markers AND `.mdx` is treated as two bundles.
*/
export function groupBundlesByStem(files: SelectedFile[]): GroupResult {
const classified = files.map(classify);
const byStem = new Map<string, SourceFile[]>();
for (const f of classified) {
if (!byStem.has(f.stem)) byStem.set(f.stem, []);
byStem.get(f.stem)!.push(f);
}
const bundles: Bundle[] = [];
const orphans: SourceFile[] = [];
for (const [stem, group] of byStem) {
const ifo = group.find((f) => f.ext === 'ifo');
const idx = group.find((f) => f.ext === 'idx');
const dict = group.find((f) => f.ext === 'dict' || f.isDictZip);
const syn = group.find((f) => f.ext === 'syn');
const mdx = group.find((f) => f.ext === 'mdx');
const mdd = group.filter((f) => f.ext === 'mdd');
if (ifo && idx && dict) {
bundles.push({ kind: 'stardict', stem, ifo, idx, dict, syn });
} else if (mdx) {
bundles.push({ kind: 'mdict', stem, mdx, mdd });
} else {
orphans.push(...group);
}
}
return { bundles, orphans };
}
/** Parse a StarDict `.ifo` (key=value, one per line) into a record. */
function parseIfo(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line || !line.includes('=')) continue;
const eq = line.indexOf('=');
out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
return out;
}
/** Detect the GZIP magic at the start of a Blob. */
async function isGzip(file: File): Promise<boolean> {
const head = new Uint8Array(await file.slice(0, 3).arrayBuffer());
return head[0] === GZIP_MAGIC[0] && head[1] === GZIP_MAGIC[1] && head[2] === GZIP_MAGIC[2];
}
async function writeBundleFile(
fs: FileSystem,
bundleDir: string,
filename: string,
source: File,
): Promise<void> {
const dst = `${bundleDir}/${filename}`;
await fs.writeFile(dst, 'Dictionaries', source);
}
/** Build a fresh bundle directory `'Dictionaries'/<id>/`. */
async function createBundleDir(fs: FileSystem): Promise<string> {
const id = uniqueId();
await fs.createDir(id, 'Dictionaries', true);
return id;
}
async function importStarDictBundle(
fs: FileSystem,
group: StarDictGroup,
): Promise<ImportedDictionary> {
const bundleDir = await createBundleDir(fs);
const ifoFile = await readSource(fs, group.ifo.source);
const idxFile = await readSource(fs, group.idx.source);
const dictFile = await readSource(fs, group.dict.source);
const synFile = group.syn ? await readSource(fs, group.syn.source) : undefined;
await writeBundleFile(fs, bundleDir, group.ifo.name, ifoFile);
await writeBundleFile(fs, bundleDir, group.idx.name, idxFile);
await writeBundleFile(fs, bundleDir, group.dict.name, dictFile);
if (synFile && group.syn) {
await writeBundleFile(fs, bundleDir, group.syn.name, synFile);
}
// Pre-compute offsets sidecars at import time. Subsequent provider inits
// skip the full `.idx` (and `.syn`) scan — the only reads are the small
// sidecar plus per-lookup probes. Net effect on cmudict-class bundles:
// ~62% init IO reduction.
const idxOffsetsName = `${group.idx.stem}.idx.offsets`;
{
const idxBytes = new Uint8Array(await idxFile.arrayBuffer());
const offsets = scanEntryOffsets(idxBytes, /* payloadBytes */ 8);
const sidecar = serializeOffsetsSidecar(offsets);
// Wrap as a File so writeFile's `string | ArrayBuffer | File` signature
// accepts it without an unsafe ArrayBuffer cast (Uint8Array.buffer is
// typed `ArrayBufferLike` in TS strict mode).
const sidecarFile = new File([new Uint8Array(sidecar)], idxOffsetsName);
await fs.writeFile(`${bundleDir}/${idxOffsetsName}`, 'Dictionaries', sidecarFile);
}
let synOffsetsName: string | undefined;
if (synFile && group.syn) {
synOffsetsName = `${group.syn.stem}.syn.offsets`;
const synBytes = new Uint8Array(await synFile.arrayBuffer());
const offsets = scanEntryOffsets(synBytes, /* payloadBytes */ 4);
const sidecar = serializeOffsetsSidecar(offsets);
const sidecarFile = new File([new Uint8Array(sidecar)], synOffsetsName);
await fs.writeFile(`${bundleDir}/${synOffsetsName}`, 'Dictionaries', sidecarFile);
}
const ifoText = await ifoFile.text();
const ifo = parseIfo(ifoText);
const name = ifo['bookname'] || group.stem;
const lang = ifo['lang'] || ifo['idxoffsetlang'] || undefined;
// v1 scope: only DictZip-compressed `.dict.dz` and single-type sametypesequence ∈ {m, h, x, t}.
// Bundles outside this surface as `unsupported` so the popup hides them
// and the settings UI shows a clear reason; the import itself still succeeds.
let unsupported = false;
let unsupportedReason: string | undefined;
if (!(await isGzip(dictFile))) {
unsupported = true;
unsupportedReason = 'Raw .dict files are not supported in v1; please use .dict.dz format.';
} else {
const seq = ifo['sametypesequence'];
if (!seq || seq.length !== 1) {
unsupported = true;
unsupportedReason = seq
? `Multi-type sametypesequence "${seq}" is not supported in v1.`
: 'StarDict bundles without sametypesequence are not supported in v1.';
} else if (!'mhxt'.includes(seq)) {
unsupported = true;
unsupportedReason = `StarDict entry type "${seq}" is not supported in v1.`;
}
}
return {
id: bundleDir,
kind: 'stardict',
name,
bundleDir,
files: {
ifo: group.ifo.name,
idx: group.idx.name,
dict: group.dict.name,
syn: group.syn?.name,
idxOffsets: idxOffsetsName,
synOffsets: synOffsetsName,
},
lang,
addedAt: Date.now(),
unsupported: unsupported || undefined,
unsupportedReason,
};
}
async function importMdictBundle(fs: FileSystem, group: MDictGroup): Promise<ImportedDictionary> {
const bundleDir = await createBundleDir(fs);
const mdxFile = await readSource(fs, group.mdx.source);
const mddFiles = await Promise.all(group.mdd.map((m) => readSource(fs, m.source)));
await writeBundleFile(fs, bundleDir, group.mdx.name, mdxFile);
for (let i = 0; i < group.mdd.length; i++) {
await writeBundleFile(fs, bundleDir, group.mdd[i]!.name, mddFiles[i]!);
}
// Parse the MDX header via the forked js-mdict (browser-friendly path).
// Loaded lazily so users without MDict imports never pull in the parser.
let name = group.stem;
let lang: string | undefined;
let unsupported = false;
let unsupportedReason: string | undefined;
try {
const { MDX } = await import('js-mdict');
const mdx = await MDX.create(mdxFile);
const header = mdx.header as Record<string, unknown>;
if (typeof header['Title'] === 'string' && (header['Title'] as string).trim()) {
name = (header['Title'] as string).trim();
}
if (typeof header['Encoding'] === 'string') {
lang = (header['Encoding'] as string).toLowerCase();
}
// `meta.encrypt` is a bitmap: 0x01 = record block encrypted (needs a
// user-supplied passcode/regcode — js-mdict doesn't implement that path),
// 0x02 = key info block encrypted (handled transparently via the
// ripemd128-based `mdxDecrypt`, no passcode needed). Only bit 0 is
// genuinely unsupported.
if ((mdx.meta.encrypt & 1) !== 0) {
unsupported = true;
unsupportedReason =
'This MDX is registered to a specific user (record-block encryption); passcode-protected dictionaries are not supported.';
}
} catch (err) {
const message = (err as Error).message ?? String(err);
unsupported = true;
if (/encrypted file|user identification/i.test(message)) {
unsupportedReason =
'This MDX is registered to a specific user (record-block encryption); passcode-protected dictionaries are not supported.';
} else {
unsupportedReason = `Failed to parse MDX header: ${message}`;
}
}
return {
id: bundleDir,
kind: 'mdict',
name,
bundleDir,
files: {
mdx: group.mdx.name,
mdd: group.mdd.map((m) => m.name),
},
lang,
addedAt: Date.now(),
unsupported: unsupported || undefined,
unsupportedReason,
};
}
export interface ImportDictionariesResult {
imported: ImportedDictionary[];
/** Filenames that didn't form a valid bundle. */
orphanFiles: string[];
}
/**
* Top-level import entry point. Groups the selected files into bundles and
* imports each one. Returns the persisted metadata for new entries plus a
* list of orphan filenames the caller can surface in a toast.
*/
export async function importDictionaries(
fs: FileSystem,
files: SelectedFile[],
): Promise<ImportDictionariesResult> {
const { bundles, orphans } = groupBundlesByStem(files);
const imported: ImportedDictionary[] = [];
for (const bundle of bundles) {
if (bundle.kind === 'stardict') {
imported.push(await importStarDictBundle(fs, bundle));
} else {
imported.push(await importMdictBundle(fs, bundle));
}
}
return { imported, orphanFiles: orphans.map((o) => o.name) };
}
/** Remove a dictionary's bundle directory. The metadata is dropped by the caller. */
export async function deleteDictionary(fs: FileSystem, dict: ImportedDictionary): Promise<void> {
if (await fs.exists(dict.bundleDir, 'Dictionaries')) {
await fs.removeDir(dict.bundleDir, 'Dictionaries', true);
}
}
@@ -0,0 +1,227 @@
/**
* MDict provider.
*
* Wraps the forked `js-mdict` `MDX` / `MDD` classes via `MDX.create(blob)` /
* `MDD.create(blob)`. Both factories accept any `Blob` whose `slice(start,
* end).arrayBuffer()` resolves the bytes — Readest's `NativeFile` (Tauri) and
* `RemoteFile` (web) qualify, so initialization reads only header + key index
* and lookups read exactly the slice they need.
*
* Resource resolution: when the rendered MDX HTML references images via
* `<img src="...">`, the provider iterates the rendered DOM after insertion,
* calls `mdd.locateBytes(key)` for each path, wraps the bytes in a Blob, and
* replaces the `src` with `URL.createObjectURL(blob)`. The provider tracks
* every URL it creates and revokes them in `dispose()`.
*
* Encrypted MDX is detected at `init()` (the constructor sets
* `meta.encrypt`) and surfaces as `unsupported`.
*/
import type { DictionaryProvider, ImportedDictionary } from '../types';
import type { DictionaryFileOpener } from './starDictProvider';
interface MDXLookupResult {
keyText: string;
definition: string | null;
}
interface MDXMeta {
encrypt?: number;
}
interface MDXHeader {
[key: string]: unknown;
}
interface MDXInstance {
meta: MDXMeta;
header: MDXHeader;
lookup(word: string): MDXLookupResult | Promise<MDXLookupResult>;
}
interface MDDInstance {
locateBytes(
key: string,
):
| { keyText: string; data: Uint8Array | null }
| Promise<{ keyText: string; data: Uint8Array | null }>;
}
export interface CreateMdictProviderArgs {
dict: ImportedDictionary;
fs: DictionaryFileOpener;
/** Localized label override; defaults to the bundle name. */
label?: string;
}
const IMG_SRC_PROTOCOL_RX = /^(?:[a-z]+:|data:|blob:|\/)/i;
/**
* Resolve `<img src="path">` references in the rendered HTML by reading bytes
* from the companion `.mdd` file(s) and substituting object URLs. Returns the
* URLs that were created so the caller can revoke them in `dispose()`.
*/
async function resolveImageResources(
container: HTMLElement,
mdds: MDDInstance[],
signal: AbortSignal,
trackedUrls: string[],
): Promise<void> {
if (!mdds.length) return;
const imgs = Array.from(container.querySelectorAll<HTMLImageElement>('img[src]'));
if (!imgs.length) return;
await Promise.all(
imgs.map(async (img) => {
if (signal.aborted) return;
const src = img.getAttribute('src');
if (!src || IMG_SRC_PROTOCOL_RX.test(src)) return;
for (const mdd of mdds) {
try {
const located = await mdd.locateBytes(src);
if (signal.aborted) return;
if (located.data) {
const blob = new Blob([new Uint8Array(located.data)]);
const url = URL.createObjectURL(blob);
trackedUrls.push(url);
img.setAttribute('src', url);
return;
}
} catch (err) {
console.warn('mdd.locateBytes failed for', src, err);
}
}
}),
);
}
export const createMdictProvider = ({
dict,
fs,
label,
}: CreateMdictProviderArgs): DictionaryProvider => {
let mdx: MDXInstance | null = null;
let mdds: MDDInstance[] = [];
let initPromise: Promise<void> | null = null;
let initError: Error | null = null;
const trackedUrls: string[] = [];
const initOnce = async (): Promise<void> => {
if (mdx) return;
if (initError) throw initError;
if (!initPromise) {
initPromise = (async () => {
const { MDX, MDD } = (await import('js-mdict')) as {
MDX: { create(file: Blob): Promise<MDXInstance> };
MDD: { create(file: Blob): Promise<MDDInstance> };
};
if (!dict.files.mdx) {
throw new Error('MDict bundle is missing the .mdx file');
}
const mdxFile = await fs.openFile(`${dict.bundleDir}/${dict.files.mdx}`, 'Dictionaries');
let mdxInst: MDXInstance;
try {
mdxInst = await MDX.create(mdxFile);
} catch (err) {
const message = (err as Error).message ?? String(err);
if (/encrypted file|user identification/i.test(message)) {
throw Object.assign(
new Error(
'This MDX is registered to a specific user (record-block encryption); passcode-protected dictionaries are not supported.',
),
{ unsupported: true },
);
}
throw err;
}
// `meta.encrypt` is a bitmap. Bit 0 (record block encryption) needs a
// user passcode and isn't implemented by js-mdict. Bit 1 (key info
// block) is handled transparently via the ripemd128-based mdxDecrypt
// — those dictionaries are fully usable.
if ((mdxInst.meta?.encrypt ?? 0) & 1) {
throw Object.assign(
new Error(
'This MDX is registered to a specific user (record-block encryption); passcode-protected dictionaries are not supported.',
),
{ unsupported: true },
);
}
const mddNames = dict.files.mdd ?? [];
const mddInsts: MDDInstance[] = [];
for (const name of mddNames) {
try {
const mddFile = await fs.openFile(`${dict.bundleDir}/${name}`, 'Dictionaries');
mddInsts.push(await MDD.create(mddFile));
} catch (err) {
console.warn('Failed to open MDD resource bundle', name, err);
}
}
mdx = mdxInst;
mdds = mddInsts;
})().catch((err) => {
initError = err instanceof Error ? err : new Error(String(err));
initPromise = null;
throw initError;
});
}
return initPromise;
};
return {
id: dict.id,
kind: 'mdict',
label: label ?? dict.name,
async lookup(word, ctx) {
try {
await initOnce();
} catch (err) {
const e = err as { unsupported?: boolean; message?: string };
if (e.unsupported) {
return { ok: false, reason: 'unsupported', message: e.message };
}
return {
ok: false,
reason: 'error',
message: `Failed to load dictionary: ${(err as Error).message}`,
};
}
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
if (!mdx) return { ok: false, reason: 'error', message: 'MDX not initialized' };
try {
const result = await mdx.lookup(word);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
if (!result.definition) return { ok: false, reason: 'empty' };
const headword = document.createElement('h1');
headword.textContent = result.keyText || word;
headword.className = 'text-lg font-bold';
ctx.container.appendChild(headword);
const body = document.createElement('div');
body.innerHTML = result.definition;
body.className = 'mt-2 text-sm';
ctx.container.appendChild(body);
await resolveImageResources(body, mdds, ctx.signal, trackedUrls);
return { ok: true, headword: result.keyText, sourceLabel: dict.name };
} catch (err) {
return {
ok: false,
reason: 'error',
message: (err as Error).message,
};
}
},
dispose() {
for (const url of trackedUrls) {
try {
URL.revokeObjectURL(url);
} catch {
// ignore — already revoked or ephemeral environment without object URLs
}
}
trackedUrls.length = 0;
},
};
};
@@ -0,0 +1,195 @@
/**
* StarDict provider.
*
* Uses {@link StarDictReader} (this repo) instead of `foliate-js/dict.js`'s
* `StarDict` class. The upstream `DictZip.read` assumes per-chunk
* independent deflate streams (BFINAL=1 + Z_FULL_FLUSH boundaries) but
* many real-world `.dict.dz` files are a single continuous deflate stream
* with the FEXTRA/RA index pointing at *uncompressed* offsets, which makes
* per-chunk inflate fail with `unexpected EOF`. Our reader gunzips the
* whole file once and slices by offset, which works for both variants and
* for raw uncompressed `.dict` files.
*
* v1 supports only single-character `sametypesequence` {m, h, x, t}:
* - `m` plain text (rendered with newline preservation)
* - `h`/`x` HTML/XHTML (set via innerHTML)
* - `t` phonetic (rendered as italic)
*
* Multi-type sequences, synonym-only bundles, image/audio types are
* flagged `unsupported` at import time and filtered out before this
* provider is instantiated.
*/
import type { DictionaryProvider, ImportedDictionary } from '../types';
import type { BaseDir } from '@/types/system';
import { StarDictReader, type StarDictEntry } from '../stardictReader';
/** Subset of the file API the provider needs. Both `AppService` and `FileSystem` satisfy this. */
export interface DictionaryFileOpener {
openFile(path: string, base: BaseDir): Promise<File>;
}
const SAFE_TYPES = new Set(['m', 'h', 'x', 't']);
const decoder = new TextDecoder('utf-8');
const renderEntry = (
container: HTMLElement,
word: string,
bytes: Uint8Array,
type: string,
isAdditional: boolean,
): void => {
const text = decoder.decode(bytes);
if (!isAdditional) {
const h1 = document.createElement('h1');
h1.textContent = word;
h1.className = 'text-lg font-bold';
container.appendChild(h1);
} else {
const h2 = document.createElement('h2');
h2.textContent = word;
h2.className = 'text-base font-semibold mt-4';
container.appendChild(h2);
}
if (type === 'h' || type === 'x') {
const div = document.createElement('div');
div.innerHTML = text;
div.className = 'mt-2 text-sm';
container.appendChild(div);
return;
}
if (type === 't') {
const em = document.createElement('em');
em.textContent = text;
em.className = 'mt-2 block text-sm italic not-eink:opacity-85';
container.appendChild(em);
return;
}
// 'm' plain — preserve newlines.
const pre = document.createElement('pre');
pre.textContent = text;
pre.className = 'mt-2 whitespace-pre-wrap break-words text-sm font-sans';
container.appendChild(pre);
};
export interface CreateStarDictProviderArgs {
dict: ImportedDictionary;
fs: DictionaryFileOpener;
/** Localized label override; defaults to the bundle name. */
label?: string;
}
/**
* Build a StarDict provider for one imported bundle. The provider lazily
* initializes its reader on first lookup so users with many dictionaries
* don't pay the parse + inflate cost up front for tabs they never open.
*/
export const createStarDictProvider = ({
dict,
fs,
label,
}: CreateStarDictProviderArgs): DictionaryProvider => {
let reader: StarDictReader | null = null;
let initPromise: Promise<StarDictReader> | null = null;
let initError: Error | null = null;
const initOnce = async (): Promise<StarDictReader> => {
if (reader) return reader;
if (initError) throw initError;
if (!initPromise) {
initPromise = (async () => {
if (!dict.files.ifo || !dict.files.idx || !dict.files.dict) {
throw new Error('StarDict bundle is missing required files');
}
// Open every bundle file in parallel. Sidecars are optional —
// older imports won't have them; the reader falls back to
// scanning the source file when they're missing.
const [ifoFile, idxFile, dictFile, synFile, idxOffsetsFile, synOffsetsFile] =
await Promise.all([
fs.openFile(`${dict.bundleDir}/${dict.files.ifo}`, 'Dictionaries'),
fs.openFile(`${dict.bundleDir}/${dict.files.idx}`, 'Dictionaries'),
fs.openFile(`${dict.bundleDir}/${dict.files.dict}`, 'Dictionaries'),
dict.files.syn
? fs.openFile(`${dict.bundleDir}/${dict.files.syn}`, 'Dictionaries')
: Promise.resolve(undefined),
dict.files.idxOffsets
? fs
.openFile(`${dict.bundleDir}/${dict.files.idxOffsets}`, 'Dictionaries')
.catch(() => undefined)
: Promise.resolve(undefined),
dict.files.synOffsets
? fs
.openFile(`${dict.bundleDir}/${dict.files.synOffsets}`, 'Dictionaries')
.catch(() => undefined)
: Promise.resolve(undefined),
]);
const r = new StarDictReader();
await r.load({
ifo: ifoFile,
idx: idxFile,
dict: dictFile,
syn: synFile,
idxOffsets: idxOffsetsFile,
synOffsets: synOffsetsFile,
});
reader = r;
return r;
})().catch((err) => {
initError = err instanceof Error ? err : new Error(String(err));
initPromise = null;
throw initError;
});
}
return initPromise;
};
return {
id: dict.id,
kind: 'stardict',
label: label ?? dict.name,
async lookup(word, ctx) {
let r: StarDictReader;
try {
r = await initOnce();
} catch (err) {
return {
ok: false,
reason: 'error',
message: `Failed to load dictionary: ${(err as Error).message}`,
};
}
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
const seq = r.ifo['sametypesequence'];
if (!seq || seq.length !== 1 || !SAFE_TYPES.has(seq)) {
return {
ok: false,
reason: 'unsupported',
message: 'StarDict format outside v1 support',
};
}
try {
let entry: StarDictEntry | undefined = await r.lookup(word);
if (!entry) entry = await r.resolveSynonym(word);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
if (!entry) return { ok: false, reason: 'empty' };
const bytes = await r.read(entry);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
if (!bytes.length) return { ok: false, reason: 'empty' };
renderEntry(ctx.container, entry.word, bytes, seq, false);
return { ok: true, headword: entry.word, sourceLabel: r.ifo['bookname'] || dict.name };
} catch (err) {
return {
ok: false,
reason: 'error',
message: (err as Error).message,
};
}
},
};
};
@@ -0,0 +1,111 @@
/**
* Built-in Wikipedia provider.
*
* Looks up the selection text in `<lang>.wikipedia.org`'s REST summary API
* and renders the title block (with optional thumbnail-as-background) plus
* the rendered HTML extract.
*
* Extracted from the legacy `WikipediaPopup.tsx`. The legacy popup used
* `document.querySelector('main')` and `document.querySelector('footer')`
* which would break inside a multi-tab popup where those globals point
* at the wrong tab. This provider writes into `ctx.container` instead.
* The footer is rendered by the shell; this provider's outcome carries
* `sourceLabel` so the shell shows attribution.
*/
import type { DictionaryProvider, DictionaryLookupOutcome } from '../types';
import { BUILTIN_PROVIDER_IDS } from '../types';
import { stubTranslation as _ } from '@/utils/misc';
import { isTauriAppPlatform } from '@/services/environment';
const isTauri = isTauriAppPlatform();
export const wikipediaProvider: DictionaryProvider = {
id: BUILTIN_PROVIDER_IDS.wikipedia,
kind: 'builtin',
label: _('Wikipedia'),
async lookup(word, ctx): Promise<DictionaryLookupOutcome> {
const bookLang = typeof ctx.lang === 'string' ? ctx.lang : ctx.lang?.[0];
const langCode = bookLang ? bookLang.split('-')[0]! : 'en';
try {
const response = await fetch(
`https://${langCode}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(word)}`,
{ signal: ctx.signal },
);
if (!response.ok) {
return { ok: false, reason: 'error', message: `HTTP ${response.status}` };
}
const data = await response.json();
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
const hgroup = document.createElement('hgroup');
hgroup.style.color = 'white';
hgroup.style.backgroundPosition = 'center center';
hgroup.style.backgroundSize = 'cover';
hgroup.style.backgroundColor = 'rgba(0, 0, 0, .4)';
hgroup.style.backgroundBlendMode = 'darken';
hgroup.style.borderRadius = '6px';
hgroup.style.padding = '12px';
hgroup.style.marginBottom = '12px';
hgroup.style.minHeight = '100px';
const h1 = document.createElement('h1');
h1.innerHTML = data.titles?.display ?? word;
h1.className = 'text-lg font-bold';
hgroup.append(h1);
if (data.description) {
const description = document.createElement('p');
description.innerText = data.description;
hgroup.appendChild(description);
}
if (data.thumbnail?.source) {
hgroup.style.backgroundImage = `url("${data.thumbnail.source}")`;
}
const contentDiv = document.createElement('div');
contentDiv.innerHTML = data.extract_html ?? '';
contentDiv.className = 'p-2 text-sm';
if (data.dir) contentDiv.dir = data.dir;
ctx.container.append(hgroup, contentDiv);
// "Read on Wikipedia" link. The REST summary endpoint returns
// `content_urls.{desktop,mobile}.page` pointing at the canonical
// article. Fall back to a constructed URL if the API ever stops
// sending content_urls (it has been stable for years, but be safe).
const articleUrl: string =
(data.content_urls?.desktop?.page as string | undefined) ??
(data.content_urls?.mobile?.page as string | undefined) ??
`https://${langCode}.wikipedia.org/wiki/${encodeURIComponent(word)}`;
const linkWrapper = document.createElement('p');
linkWrapper.className = 'mt-3 px-2 text-sm';
const link = document.createElement('a');
link.href = articleUrl;
// Skip target="_blank" on Tauri. iOS WebView dispatches a separate
// "open externally" path for `_blank` anchors that goes through the
// shell scope and fails with "Operation not permitted" — even when
// a click handler `preventDefault`s. The popup's container click
// handler routes the click through `openUrl` instead.
if (!isTauri) link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'not-eink:text-primary underline';
link.textContent = _('Read on Wikipedia →');
linkWrapper.appendChild(link);
ctx.container.append(linkWrapper);
return { ok: true, headword: word, sourceLabel: 'Wikipedia (CC BY-SA)' };
} catch (error) {
if ((error as { name?: string }).name === 'AbortError') {
return { ok: false, reason: 'error', message: 'aborted' };
}
console.error('Wikipedia lookup failed', error);
return {
ok: false,
reason: 'error',
message: error instanceof Error ? error.message : String(error),
};
}
},
};
@@ -0,0 +1,186 @@
/**
* Built-in Wiktionary provider.
*
* Looks up the headword in en.wiktionary.org's REST API. For CJK headwords
* (lang code starts with `zh`/`zho`), falls back to {@link fetchChineseDefinition}
* which scrapes Wiktionary's wikitext for pinyin + meanings.
*
* In-popup link interception: any `a[rel="mw:WikiLink"]` in a definition is
* rewritten to call `ctx.onNavigate(title)` instead of navigating away. The
* shell uses this to push onto the per-tab history.
*
* Extracted from the legacy `WiktionaryPopup.tsx`. The fetch + DOM-build
* code is functionally identical; the only change is writing into
* `ctx.container` instead of a global `<main>` element so the renderer can
* coexist with other tabs in the same popup.
*/
import type { DictionaryProvider, DictionaryLookupOutcome } from '../types';
import { BUILTIN_PROVIDER_IDS } from '../types';
import { fetchChineseDefinition } from '../chineseDict';
import { normalizedLangCode } from '@/utils/lang';
import { stubTranslation as _ } from '@/utils/misc';
type Definition = {
definition: string;
examples?: string[];
};
type Result = {
partOfSpeech: string;
definitions: Definition[];
language: string;
};
const interceptDictLinks = (
definitionHtml: string,
onNavigate?: (word: string) => void,
): HTMLElement[] => {
const wrapper = document.createElement('div');
wrapper.innerHTML = definitionHtml;
const links = wrapper.querySelectorAll<HTMLAnchorElement>('a[rel="mw:WikiLink"]');
links.forEach((link) => {
const title = link.getAttribute('title');
if (!title) return;
link.addEventListener('click', (event) => {
event.preventDefault();
onNavigate?.(title);
});
link.className = 'not-eink:text-primary underline cursor-pointer';
});
return Array.from(wrapper.childNodes) as HTMLElement[];
};
const renderChinese = async (
word: string,
container: HTMLElement,
signal: AbortSignal,
): Promise<DictionaryLookupOutcome> => {
const entry = await fetchChineseDefinition(word);
if (signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
if (!entry) return { ok: false, reason: 'empty' };
const hgroup = document.createElement('hgroup');
const h1 = document.createElement('h1');
h1.textContent = entry.word;
h1.className = 'text-lg font-bold';
hgroup.append(h1);
if (entry.pinyin) {
const pinyinEl = document.createElement('p');
pinyinEl.textContent = entry.pinyin;
pinyinEl.className = 'text-base italic not-eink:opacity-85';
hgroup.append(pinyinEl);
}
const langEl = document.createElement('p');
langEl.textContent = 'Chinese';
langEl.className = 'text-sm italic not-eink:opacity-75';
hgroup.append(langEl);
container.append(hgroup);
entry.definitions.forEach(({ partOfSpeech, meanings }) => {
const h2 = document.createElement('h2');
h2.textContent = partOfSpeech;
h2.className = 'text-base font-semibold mt-4';
const ol = document.createElement('ol');
ol.className = 'pl-8 list-decimal';
meanings.forEach((meaning) => {
const li = document.createElement('li');
li.textContent = meaning;
ol.appendChild(li);
});
container.appendChild(h2);
container.appendChild(ol);
});
return { ok: true, headword: entry.word, sourceLabel: 'Wiktionary (CC BY-SA)' };
};
const renderWiktionary = async (
word: string,
language: string | undefined,
container: HTMLElement,
signal: AbortSignal,
onNavigate?: (word: string) => void,
): Promise<DictionaryLookupOutcome> => {
const response = await fetch(
`https://en.wiktionary.org/api/rest_v1/page/definition/${encodeURIComponent(word)}`,
{ signal },
);
if (!response.ok) {
return { ok: false, reason: 'error', message: `HTTP ${response.status}` };
}
const json = await response.json();
if (signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
const results: Result[] | undefined = language
? json[language] || json['en']
: json[Object.keys(json)[0]!];
if (!results || results.length === 0) {
return { ok: false, reason: 'empty' };
}
const hgroup = document.createElement('hgroup');
const h1 = document.createElement('h1');
h1.textContent = word;
h1.className = 'text-lg font-bold';
const p = document.createElement('p');
p.textContent = results[0]!.language;
p.className = 'text-sm italic not-eink:opacity-75';
hgroup.append(h1, p);
container.append(hgroup);
results.forEach(({ partOfSpeech, definitions }: Result) => {
const h2 = document.createElement('h2');
h2.textContent = partOfSpeech;
h2.className = 'text-base font-semibold mt-4';
const ol = document.createElement('ol');
ol.className = 'pl-8 list-decimal';
definitions.forEach(({ definition, examples }: Definition) => {
if (!definition) return;
const li = document.createElement('li');
const processed = interceptDictLinks(definition, onNavigate);
li.append(...processed);
if (examples) {
const ul = document.createElement('ul');
ul.className = 'pl-8 list-disc text-sm italic not-eink:opacity-75';
examples.forEach((example) => {
const exampleLi = document.createElement('li');
exampleLi.innerHTML = example;
ul.appendChild(exampleLi);
});
li.appendChild(ul);
}
ol.appendChild(li);
});
container.appendChild(h2);
container.appendChild(ol);
});
return { ok: true, headword: word, sourceLabel: 'Wiktionary (CC BY-SA)' };
};
export const wiktionaryProvider: DictionaryProvider = {
id: BUILTIN_PROVIDER_IDS.wiktionary,
kind: 'builtin',
label: _('Wiktionary'),
async lookup(word, ctx) {
const langCode = typeof ctx.lang === 'string' ? ctx.lang : ctx.lang?.[0];
const isChinese = langCode ? normalizedLangCode(langCode) === 'zh' : false;
try {
if (isChinese) {
return await renderChinese(word, ctx.container, ctx.signal);
}
return await renderWiktionary(word, langCode, ctx.container, ctx.signal, ctx.onNavigate);
} catch (error) {
if ((error as { name?: string }).name === 'AbortError') {
return { ok: false, reason: 'error', message: 'aborted' };
}
console.error('Wiktionary lookup failed', error);
return {
ok: false,
reason: 'error',
message: error instanceof Error ? error.message : String(error),
};
}
},
};
@@ -0,0 +1,110 @@
/**
* Dictionary provider registry.
*
* Returns the ordered list of {@link DictionaryProvider} instances visible
* in the lookup popup, given the user's current `dictionarySettings` (order,
* enable flags), the imported-dictionary metadata, and the filesystem
* accessor used by import-backed providers (StarDict / MDict) to lazily open
* their bundle files.
*
* Provider instances are cached at module scope (keyed by id) so subsequent
* lookups within the same session reuse parsed indexes / object URLs. This
* cache is **not** in zustand: provider instances and their object URLs are
* runtime-only and non-serializable; storing them alongside metadata would
* pollute the synced settings shape.
*/
import type { DictionaryProvider, DictionarySettings, ImportedDictionary } from './types';
import { BUILTIN_PROVIDER_IDS } from './types';
import { wiktionaryProvider } from './providers/wiktionaryProvider';
import { wikipediaProvider } from './providers/wikipediaProvider';
import { createStarDictProvider, type DictionaryFileOpener } from './providers/starDictProvider';
import { createMdictProvider } from './providers/mdictProvider';
const instanceCache = new Map<string, DictionaryProvider>();
interface RegistryArgs {
settings: DictionarySettings;
dictionaries: ImportedDictionary[];
/**
* Required when the provider order contains imported (stardict / mdict)
* dictionaries their providers open files via this accessor on first
* lookup. Builtin-only callers (e.g. tests) may omit it.
*/
fs?: DictionaryFileOpener;
}
const builtinFor = (id: string): DictionaryProvider | undefined => {
if (id === BUILTIN_PROVIDER_IDS.wiktionary) return wiktionaryProvider;
if (id === BUILTIN_PROVIDER_IDS.wikipedia) return wikipediaProvider;
return undefined;
};
const getOrCreate = (
id: string,
dict: ImportedDictionary | undefined,
fs: DictionaryFileOpener | undefined,
): DictionaryProvider | undefined => {
const cached = instanceCache.get(id);
if (cached) return cached;
const builtin = builtinFor(id);
if (builtin) {
instanceCache.set(id, builtin);
return builtin;
}
if (!dict) return undefined;
if (!fs) return undefined;
if (dict.kind === 'stardict') {
const provider = createStarDictProvider({ dict, fs });
instanceCache.set(id, provider);
return provider;
}
if (dict.kind === 'mdict') {
const provider = createMdictProvider({ dict, fs });
instanceCache.set(id, provider);
return provider;
}
return undefined;
};
/**
* Returns the ordered list of enabled providers ready for the popup.
* - Filters out disabled ids.
* - Filters out imported entries that are soft-deleted, unavailable on this
* device, or flagged unsupported.
* - Preserves the order in `settings.providerOrder`.
*/
export const getEnabledProviders = ({
settings,
dictionaries,
fs,
}: RegistryArgs): DictionaryProvider[] => {
const dictById = new Map(dictionaries.map((d) => [d.id, d]));
const out: DictionaryProvider[] = [];
for (const id of settings.providerOrder) {
if (settings.providerEnabled[id] === false) continue;
if (id.startsWith('builtin:')) {
const provider = getOrCreate(id, undefined, undefined);
if (provider) out.push(provider);
continue;
}
const dict = dictById.get(id);
if (!dict) continue;
if (dict.deletedAt || dict.unavailable || dict.unsupported) continue;
const provider = getOrCreate(id, dict, fs);
if (provider) out.push(provider);
}
return out;
};
/** Drop a single provider from the cache (e.g. after dictionary deletion). */
export const evictProvider = (id: string): void => {
const cached = instanceCache.get(id);
cached?.dispose?.();
instanceCache.delete(id);
};
/** Drop everything. Test helper. */
export const __resetRegistryForTests = (): void => {
for (const [, provider] of instanceCache) provider.dispose?.();
instanceCache.clear();
};
@@ -0,0 +1,624 @@
/**
* Self-contained StarDict reader with lazy random-access binary search
* across all three bundle parts: `.idx`, `.syn`, and `.dict.dz`.
*
* Replaces `foliate-js/dict.js`'s `StarDict` / `DictZip`. The upstream
* `DictZip.read` calls `inflateSync` on each chunk, but per-chunk DictZip
* data ends at `Z_FULL_FLUSH` boundaries (BFINAL=0) `inflateSync`
* rejects those with `unexpected EOF`. fflate's streaming `Inflate` class
* accepts non-final input and emits chunk bytes via `ondata`; that's what
* we use here.
*
* # `.idx` / `.syn`: lazy random-access binary search
*
* Each is a sorted list of variable-length records:
* `<word-bytes>\0<payload>`
* (`.idx` payload = 8 bytes; `.syn` payload = 4 bytes.)
*
* Eagerly parsing all entries into JS objects is heap-expensive: cmudict's
* 105K entries cost ~10 MB. We instead:
*
* 1. Scan the bytes once at init to find every entry's start offset.
* Stored as an `Int32Array` (cmudict: 420 KB). The raw bytes are
* then dropped the original Blob stays alive for slice reads.
* 2. At lookup time, binary search the offsets. Each probe reads one
* entry's bytes (~16 B) from the Blob, decodes, compares.
* 3. LRU-cache decoded entries (default 256).
*
* `.syn` further defers its offset scan until first synonym fallback
* sessions that never miss the primary index pay nothing for synonyms.
* An optional offsets sidecar (see {@link serializeOffsetsSidecar}) lets
* init skip the offset scan entirely.
*
* # `.dict.dz`: lazy chunk decompression
*
* DictZip files have a FEXTRA/RA subfield listing per-chunk compressed
* sizes; chunks are separated by `Z_FULL_FLUSH` so each chunk's
* uncompressed bytes are exactly `chlen` long (the last may be shorter).
*
* At init we parse the FEXTRA, then probe-inflate chunk 0 with streaming
* `Inflate` to confirm it works. If yes (the common case for properly-
* tooled `.dict.dz` files like cmudict and eng-nld), we keep only the
* chunk metadata (~few KB) and the original Blob. Each lookup reads only
* the chunks containing the entry's uncompressed range, inflates them
* via streaming `Inflate`, and caches the decompressed output (LRU,
* default 16 chunks 1 MB).
*
* If FEXTRA/RA is missing or the probe fails, we fall back to whole-file
* gunzip at init and slice the in-memory buffer thereafter same as
* before.
*
* Net effect (cmudict):
* Init heap before: ~1.3 MB (whole inflated dict) + ~10 MB (parsed idx).
* Init heap after: ~420 KB (idx offsets) + chunk metadata (~few KB)
* + LRU chunk cache ( ~1 MB after warmup).
*/
import { gunzipSync, Inflate } from 'fflate';
export interface StarDictEntry {
word: string;
offset: number;
size: number;
}
const decoder = new TextDecoder('utf-8');
const GZIP_MAGIC = [0x1f, 0x8b];
const isGzip = (bytes: Uint8Array): boolean =>
bytes.length >= 2 && bytes[0] === GZIP_MAGIC[0] && bytes[1] === GZIP_MAGIC[1];
/** Parse the key=value `.ifo` text into a record. */
export function parseIfo(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line || !line.includes('=')) continue;
const eq = line.indexOf('=');
out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
return out;
}
/**
* Scan a `.idx` (or `.syn`) byte buffer to find every entry's start offset.
* Returns an `Int32Array` of byte offsets one per entry.
*
* Each entry: `<word-bytes>\0<payload>`. The payload is fixed-size:
* - `.idx`: 8 bytes (offset:u32be + size:u32be)
* - `.syn`: 4 bytes (idx-index:u32be)
*/
export function scanEntryOffsets(bytes: Uint8Array, payloadBytes: number): Int32Array {
const offsets: number[] = [];
let i = 0;
while (i < bytes.length) {
offsets.push(i);
while (i < bytes.length && bytes[i] !== 0) i++;
if (i >= bytes.length) break;
i += 1 + payloadBytes; // skip null terminator + payload
}
return new Int32Array(offsets);
}
// ---------------------------------------------------------------------------
// Offset sidecar serialization.
//
// Format:
// bytes 0-3: magic 'SDOF' (StarDict OFfsets)
// bytes 4-7: u32 little-endian version (current = 1)
// bytes 8+: raw Int32Array little-endian payload (one i32 per entry start)
//
// LE byte order is used unconditionally — every platform we ship to (web,
// Tauri on x86 / ARM64) is LE. If we ever need cross-endian sync, version-bump
// and add a byte-swap path.
// ---------------------------------------------------------------------------
const SIDECAR_MAGIC = [0x53, 0x44, 0x4f, 0x46]; // 'SDOF'
const SIDECAR_VERSION = 1;
const SIDECAR_HEADER_SIZE = 8;
/** Serialize an offsets array to a single allocation suitable for `fs.writeFile`. */
export function serializeOffsetsSidecar(offsets: Int32Array): Uint8Array {
const out = new Uint8Array(SIDECAR_HEADER_SIZE + offsets.byteLength);
out[0] = SIDECAR_MAGIC[0]!;
out[1] = SIDECAR_MAGIC[1]!;
out[2] = SIDECAR_MAGIC[2]!;
out[3] = SIDECAR_MAGIC[3]!;
// Version (u32 LE).
const view = new DataView(out.buffer);
view.setUint32(4, SIDECAR_VERSION, true);
// Payload — direct memcpy of the Int32Array's bytes.
out.set(
new Uint8Array(offsets.buffer, offsets.byteOffset, offsets.byteLength),
SIDECAR_HEADER_SIZE,
);
return out;
}
/** Parse a sidecar blob. Returns `null` for missing / wrong-magic / wrong-version. */
export function parseOffsetsSidecar(bytes: Uint8Array): Int32Array | null {
if (bytes.length < SIDECAR_HEADER_SIZE) return null;
for (let i = 0; i < SIDECAR_MAGIC.length; i++) {
if (bytes[i] !== SIDECAR_MAGIC[i]) return null;
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const version = view.getUint32(4, true);
if (version !== SIDECAR_VERSION) return null;
const payloadLen = bytes.byteLength - SIDECAR_HEADER_SIZE;
if (payloadLen % 4 !== 0) return null;
// Copy into a freshly-allocated Int32Array so the consumer owns aligned
// memory regardless of how `bytes` was sliced upstream.
const out = new Int32Array(payloadLen / 4);
const src = new Int32Array(bytes.buffer, bytes.byteOffset + SIDECAR_HEADER_SIZE, payloadLen / 4);
out.set(src);
return out;
}
const cmpAscii = (a: string, b: string): number => {
const x = a.toLowerCase();
const y = b.toLowerCase();
return x < y ? -1 : x > y ? 1 : 0;
};
/**
* Bounded LRU. Tiny enough we don't bother with a real linked-list
* implementation; reusing the Map insertion order is fine.
*/
class LRU<K, V> {
private readonly max: number;
private readonly map = new Map<K, V>();
constructor(max: number) {
this.max = max;
}
get(key: K): V | undefined {
const v = this.map.get(key);
if (v !== undefined) {
// Move to end (most-recently-used).
this.map.delete(key);
this.map.set(key, v);
}
return v;
}
set(key: K, value: V): void {
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.max) {
// Evict oldest.
const first = this.map.keys().next().value as K | undefined;
if (first !== undefined) this.map.delete(first);
}
}
}
// ---------------------------------------------------------------------------
// DictZip parsing + lazy chunk decompression.
//
// A `.dict.dz` file is a standard gzip with a FEXTRA "RA" subfield
// listing the compressed length of each chunk. Each chunk's uncompressed
// length is exactly `chlen` (the last may be shorter). Chunks are
// separated by `Z_FULL_FLUSH` (BFINAL=0 + sync marker), so each chunk's
// deflate stream is non-terminating — `inflateSync` rejects them with
// "unexpected EOF". fflate's streaming `Inflate.push(bytes, false)`
// handles them correctly and emits chunk bytes via `ondata`.
// ---------------------------------------------------------------------------
interface DictZipMeta {
/** Uncompressed bytes per chunk (last may be shorter). */
chlen: number;
/** Compressed bytes per chunk; sums to `compressedDataSize`. */
chunkSizes: number[];
/** Byte offset within the file where compressed chunk data begins. */
compressedDataOffset: number;
}
/**
* Parse the gzip header + FEXTRA RA subfield. Returns `null` for
* non-gzip files or gzip files without an RA subfield (those need
* whole-file gunzip).
*/
function parseDictZipHeader(bytes: Uint8Array): DictZipMeta | null {
if (bytes.length < 12 || bytes[0] !== 0x1f || bytes[1] !== 0x8b || bytes[2] !== 0x08) {
return null;
}
const flg = bytes[3]!;
const hasFEXTRA = (flg & 0b100) !== 0;
if (!hasFEXTRA) return null;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const xlen = view.getUint16(10, true);
let chlen = 0;
let chcnt = 0;
const chunkSizes: number[] = [];
let p = 12;
while (p + 4 <= 12 + xlen) {
const si1 = bytes[p]!;
const si2 = bytes[p + 1]!;
const slen = view.getUint16(p + 2, true);
if (si1 === 0x52 && si2 === 0x41) {
// RA subfield: ver(2) + chlen(2) + chcnt(2) + chcnt × chunkSize(2).
const ver = view.getUint16(p + 4, true);
if (ver !== 1) return null;
chlen = view.getUint16(p + 6, true);
chcnt = view.getUint16(p + 8, true);
for (let i = 0; i < chcnt; i++) {
chunkSizes.push(view.getUint16(p + 10 + 2 * i, true));
}
}
p += 4 + slen;
}
if (chcnt === 0 || chunkSizes.length !== chcnt) return null;
// Skip past FEXTRA + optional FNAME, FCOMMENT, FHCRC.
let offset = 12 + xlen;
if (flg & 0b1000) {
while (offset < bytes.length && bytes[offset] !== 0) offset++;
offset++;
}
if (flg & 0b10000) {
while (offset < bytes.length && bytes[offset] !== 0) offset++;
offset++;
}
if (flg & 0b10) offset += 2;
return { chlen, chunkSizes, compressedDataOffset: offset };
}
/**
* Inflate a single DictZip chunk via fflate's streaming `Inflate`. The
* chunk's deflate stream ends at `Z_FULL_FLUSH` (BFINAL=0 `inflateSync`
* would reject), so we push with `final=false` and capture the data
* emitted by `ondata`. Returns `null` on failure.
*/
function inflateChunkStreaming(chunkBytes: Uint8Array): Uint8Array | null {
let result: Uint8Array | null = null;
try {
const inf = new Inflate();
inf.ondata = (data, _final) => {
// The first `ondata` carries this chunk's uncompressed bytes; any
// subsequent calls would be from feeding further input (we don't).
if (result === null) result = data;
};
inf.push(chunkBytes, false);
} catch {
return null;
}
if (!result || (result as Uint8Array).length === 0) return null;
return result;
}
class DictZipChunkedDict {
private blob: Blob;
private meta: DictZipMeta;
private chunkOffsets: number[];
/** Total uncompressed dict size, derived from chunk count × chlen (last chunk may be shorter; we don't know its exact size yet). */
private chunkCache: LRU<number, Uint8Array>;
constructor(blob: Blob, meta: DictZipMeta, cacheSize: number) {
this.blob = blob;
this.meta = meta;
this.chunkCache = new LRU<number, Uint8Array>(cacheSize);
// Precompute starting compressed offset of each chunk for slice reads.
const offsets: number[] = [];
let acc = meta.compressedDataOffset;
for (const cs of meta.chunkSizes) {
offsets.push(acc);
acc += cs;
}
this.chunkOffsets = offsets;
}
/** Read `size` uncompressed bytes starting at `offset`. */
async read(offset: number, size: number): Promise<Uint8Array> {
const chlen = this.meta.chlen;
const startChunk = Math.floor(offset / chlen);
const endChunk = Math.floor((offset + size - 1) / chlen);
if (startChunk === endChunk) {
const chunk = await this.getChunk(startChunk);
const local = offset - startChunk * chlen;
return chunk.subarray(local, local + size);
}
// Spans multiple chunks — concatenate.
const parts: Uint8Array[] = [];
let totalLen = 0;
for (let i = startChunk; i <= endChunk; i++) {
const chunk = await this.getChunk(i);
parts.push(chunk);
totalLen += chunk.length;
}
const combined = new Uint8Array(totalLen);
let pos = 0;
for (const p of parts) {
combined.set(p, pos);
pos += p.length;
}
const local = offset - startChunk * chlen;
return combined.subarray(local, local + size);
}
private async getChunk(i: number): Promise<Uint8Array> {
const cached = this.chunkCache.get(i);
if (cached) return cached;
const start = this.chunkOffsets[i]!;
const compressedSize = this.meta.chunkSizes[i]!;
const compressed = new Uint8Array(
await this.blob.slice(start, start + compressedSize).arrayBuffer(),
);
const inflated = inflateChunkStreaming(compressed);
if (!inflated) throw new Error(`Failed to inflate DictZip chunk ${i}`);
this.chunkCache.set(i, inflated);
return inflated;
}
}
export interface StarDictReaderOpts {
ifo: Blob;
idx: Blob;
/** Either `.dict.dz` (gzip) or a raw `.dict` (no compression). */
dict: Blob;
syn?: Blob;
/**
* Optional `.idx.offsets` sidecar (see {@link serializeOffsetsSidecar}).
* When provided and valid, init skips the full `.idx` scan the only
* `.idx` reads are the small per-lookup probes.
*/
idxOffsets?: Blob;
/** Optional `.syn.offsets` sidecar — same idea for `.syn`. */
synOffsets?: Blob;
/** LRU cache size for decoded entries. Defaults to 256. */
cacheSize?: number;
}
export class StarDictReader {
ifo: Record<string, string> = {};
// Dict-reading mode. Exactly one of these is populated at init:
// - `dictChunked`: lazy chunk decompression (proper DictZip files).
// - `dictBuffer`: whole-file gunzip in memory (fallback when FEXTRA
// is absent or chunk-probe fails).
private dictChunked: DictZipChunkedDict | null = null;
private dictBuffer: Uint8Array = new Uint8Array();
// .idx state — populated eagerly at init.
private idxBlob: Blob | null = null;
/** Byte offset of each entry's start within `.idx`. */
private idxOffsets: Int32Array = new Int32Array(0);
/** Number of entries (= idxOffsets.length, cached for hot path). */
private idxCount = 0;
// .syn state — populated lazily on first {@link resolveSynonym} call.
private synBlob: Blob | null = null;
private synOffsets: Int32Array = new Int32Array(0);
private synCount = 0;
private synBuilt = false;
private synBuildPromise: Promise<void> | null = null;
// LRU caches — keyed by entry index within their respective offset arrays.
private idxCache: LRU<number, StarDictEntry>;
private synCache: LRU<number, { syn: string; idxIndex: number }>;
constructor(cacheSize = 256) {
this.idxCache = new LRU<number, StarDictEntry>(cacheSize);
this.synCache = new LRU<number, { syn: string; idxIndex: number }>(cacheSize);
}
async load(opts: StarDictReaderOpts): Promise<void> {
const cacheSize = opts.cacheSize ?? 256;
if (opts.cacheSize !== undefined) {
this.idxCache = new LRU<number, StarDictEntry>(cacheSize);
this.synCache = new LRU<number, { syn: string; idxIndex: number }>(cacheSize);
}
// Read the small files in parallel. We deliberately skip
// `opts.dict.arrayBuffer()` here — the dict is read in fragments
// below depending on whether lazy chunk mode is viable.
const [ifoBuf, idxOffsetsBuf, synOffsetsBuf, dictHeadBuf] = await Promise.all([
opts.ifo.arrayBuffer(),
opts.idxOffsets ? opts.idxOffsets.arrayBuffer() : Promise.resolve(undefined),
opts.synOffsets ? opts.synOffsets.arrayBuffer() : Promise.resolve(undefined),
// Read just the gzip header + FEXTRA region (much less than the
// whole file). 64 KB is enormously generous for a FEXTRA RA
// subfield — even thousands of chunks would fit in a few KB.
opts.dict.slice(0, Math.min(opts.dict.size, 64 * 1024)).arrayBuffer(),
]);
this.ifo = parseIfo(decoder.decode(new Uint8Array(ifoBuf)));
const offsetBits = this.ifo['idxoffsetbits'] ? parseInt(this.ifo['idxoffsetbits'], 10) : 32;
if (offsetBits !== 32) {
throw new Error(`StarDict idxoffsetbits=${offsetBits} not supported (only 32)`);
}
// Resolve the .idx offsets from sidecar if available + valid; otherwise
// fall back to scanning the raw .idx bytes.
let idxOffsets: Int32Array | null = null;
if (idxOffsetsBuf) {
idxOffsets = parseOffsetsSidecar(new Uint8Array(idxOffsetsBuf));
}
if (!idxOffsets) {
const idxBytes = new Uint8Array(await opts.idx.arrayBuffer());
idxOffsets = scanEntryOffsets(idxBytes, /* payloadBytes */ 8);
}
this.idxOffsets = idxOffsets;
this.idxCount = idxOffsets.length;
this.idxBlob = opts.idx;
// Try lazy chunk mode for `.dict.dz`. Parse FEXTRA, probe-inflate
// chunk 0; on success, retain only the metadata + Blob. On failure,
// fall back to whole-file gunzip.
const dictHead = new Uint8Array(dictHeadBuf);
if (isGzip(dictHead)) {
const meta = parseDictZipHeader(dictHead);
if (meta && (await this.probeChunkInflate(opts.dict, meta))) {
this.dictChunked = new DictZipChunkedDict(
opts.dict,
meta,
/* chunk LRU size */ Math.max(8, Math.floor(cacheSize / 16)),
);
} else {
// FEXTRA missing or chunk probe failed — gunzip the whole file.
const dictBytes = new Uint8Array(await opts.dict.arrayBuffer());
this.dictBuffer = gunzipSync(dictBytes);
}
} else {
// Raw .dict (no compression): keep bytes as-is.
this.dictBuffer = new Uint8Array(await opts.dict.arrayBuffer());
}
// .syn: keep the Blob, accept its sidecar eagerly if provided. If not,
// the offset table is built lazily on first synonym fallback.
if (opts.syn) {
this.synBlob = opts.syn;
if (synOffsetsBuf) {
const parsed = parseOffsetsSidecar(new Uint8Array(synOffsetsBuf));
if (parsed) {
this.synOffsets = parsed;
this.synCount = parsed.length;
this.synBuilt = true;
}
}
}
}
/**
* Read chunk 0 of a candidate DictZip and try to inflate it via the
* streaming inflater. If that succeeds and produces chlen bytes, the
* file's chunks are independently decompressible lazy mode is viable.
*/
private async probeChunkInflate(blob: Blob, meta: DictZipMeta): Promise<boolean> {
if (meta.chunkSizes.length === 0) return false;
const cs = meta.chunkSizes[0]!;
const start = meta.compressedDataOffset;
const compressed = new Uint8Array(await blob.slice(start, start + cs).arrayBuffer());
const inflated = inflateChunkStreaming(compressed);
return !!inflated && inflated.length > 0 && inflated.length <= meta.chlen;
}
/**
* Resolve an entry's bytes from the dict.
*
* Lazy-chunk mode: reads the chunks containing the entry's uncompressed
* range from the dict Blob and streaming-inflates them.
* Whole-file mode: in-memory subarray.
*/
async read(entry: StarDictEntry): Promise<Uint8Array> {
if (this.dictChunked) {
return this.dictChunked.read(entry.offset, entry.size);
}
return this.dictBuffer.subarray(entry.offset, entry.offset + entry.size);
}
/** Number of entries — exposed for tests. */
get entryCount(): number {
return this.idxCount;
}
/**
* Decode one `.idx` entry. Each entry's bytes span
* `[idxOffsets[i], idxOffsets[i+1])` (or to end-of-file for the last).
* Cached in `idxCache`.
*/
private async decodeIdxEntry(i: number): Promise<StarDictEntry> {
const cached = this.idxCache.get(i);
if (cached) return cached;
if (!this.idxBlob) throw new Error('idx blob not loaded');
const start = this.idxOffsets[i]!;
const end = i + 1 < this.idxCount ? this.idxOffsets[i + 1]! : this.idxBlob.size;
const bytes = new Uint8Array(await this.idxBlob.slice(start, end).arrayBuffer());
let nullPos = 0;
while (nullPos < bytes.length && bytes[nullPos] !== 0) nullPos++;
const word = decoder.decode(bytes.subarray(0, nullPos));
const view = new DataView(bytes.buffer, bytes.byteOffset + nullPos + 1, 8);
const offset = view.getUint32(0);
const size = view.getUint32(4);
const entry: StarDictEntry = { word, offset, size };
this.idxCache.set(i, entry);
return entry;
}
/**
* Look up a headword. Returns `undefined` when absent.
*
* Lazy random-access binary search: log2(N) probes, each reading one
* entry's worth of bytes (~16) from the .idx Blob.
*/
async lookup(word: string): Promise<StarDictEntry | undefined> {
let lo = 0;
let hi = this.idxCount - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const entry = await this.decodeIdxEntry(mid);
const cmp = cmpAscii(word, entry.word);
if (cmp === 0) return entry;
if (cmp > 0) lo = mid + 1;
else hi = mid - 1;
}
return undefined;
}
private async ensureSynBuilt(): Promise<void> {
if (this.synBuilt) return;
if (!this.synBlob) {
this.synBuilt = true;
return;
}
if (!this.synBuildPromise) {
this.synBuildPromise = (async () => {
const synBytes = new Uint8Array(await this.synBlob!.arrayBuffer());
this.synOffsets = scanEntryOffsets(synBytes, /* payloadBytes */ 4);
this.synCount = this.synOffsets.length;
this.synBuilt = true;
})();
}
await this.synBuildPromise;
}
private async decodeSynEntry(i: number): Promise<{ syn: string; idxIndex: number }> {
const cached = this.synCache.get(i);
if (cached) return cached;
if (!this.synBlob) throw new Error('syn blob not loaded');
const start = this.synOffsets[i]!;
const end = i + 1 < this.synCount ? this.synOffsets[i + 1]! : this.synBlob.size;
const bytes = new Uint8Array(await this.synBlob.slice(start, end).arrayBuffer());
let nullPos = 0;
while (nullPos < bytes.length && bytes[nullPos] !== 0) nullPos++;
const syn = decoder.decode(bytes.subarray(0, nullPos));
const view = new DataView(bytes.buffer, bytes.byteOffset + nullPos + 1, 4);
const idxIndex = view.getUint32(0);
const entry = { syn, idxIndex };
this.synCache.set(i, entry);
return entry;
}
/**
* Resolve a synonym to its underlying `.idx` entry. `undefined` when no
* `.syn` file is loaded or the synonym isn't present.
*
* On first call, scans the `.syn` blob to build its offset table.
*/
async resolveSynonym(word: string): Promise<StarDictEntry | undefined> {
await this.ensureSynBuilt();
if (!this.synCount) return undefined;
let lo = 0;
let hi = this.synCount - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const entry = await this.decodeSynEntry(mid);
const cmp = cmpAscii(word, entry.syn);
if (cmp === 0) return this.decodeIdxEntry(entry.idxIndex);
if (cmp > 0) lo = mid + 1;
else hi = mid - 1;
}
return undefined;
}
}
@@ -0,0 +1,110 @@
/**
* Pluggable dictionary provider model.
*
* Built-in providers (Wiktionary, Wikipedia) and importable providers
* (StarDict, MDict) all implement {@link DictionaryProvider}. The
* {@link DictionaryPopup} renders one tab per enabled provider in user-defined
* order; each provider writes lookup output into a per-tab container.
*/
export type DictionaryProviderKind = 'builtin' | 'stardict' | 'mdict';
export interface DictionaryLookupContext {
/** Source language hint, e.g. book primary language code (`en`, `zh`). */
lang?: string;
/** Cancel signal for in-flight network/IO. */
signal: AbortSignal;
/** Tab pane to render into. Provider populates this; never touch `document` selectors. */
container: HTMLElement;
/**
* Called when the provider intercepts an in-popup link click and wants the
* shell to navigate (push to per-tab history). Optional providers without
* cross-link navigation can ignore it.
*/
onNavigate?(word: string): void;
}
export type DictionaryLookupOutcome =
| { ok: true; headword?: string; sourceLabel?: string }
| { ok: false; reason: 'empty' | 'unsupported' | 'error'; message?: string };
export interface DictionaryProvider {
/** Stable id, e.g. `builtin:wiktionary`, `stardict:abc123`, `mdict:xyz`. */
id: string;
kind: DictionaryProviderKind;
/** Localized label shown in the tab strip. */
label: string;
/** Optional eager init (parse index/keylist). Called once on first activation. */
init?(): Promise<void>;
/** Look up a word; populate `ctx.container`; return outcome. */
lookup(word: string, ctx: DictionaryLookupContext): Promise<DictionaryLookupOutcome>;
/** Release object URLs / caches. Called when the provider is removed or replaced. */
dispose?(): void;
}
/**
* Persisted metadata for an imported dictionary. The binary files live on disk
* under {@link BaseDir} `'Dictionaries'`/<id>/; only this metadata syncs.
*/
export interface ImportedDictionary {
id: string;
kind: 'stardict' | 'mdict';
/** Display name, derived from `.ifo` `bookname` or `.mdx` header `Title`. */
name: string;
/** Subdirectory under `'Dictionaries'` containing this bundle's files. */
bundleDir: string;
/** Filenames inside `bundleDir`. The exact set varies by `kind`. */
files: {
ifo?: string;
idx?: string;
dict?: string;
syn?: string;
/**
* Pre-computed offsets sidecar for `.idx`. Generated at import time;
* lets `StarDictReader.load` skip the full `.idx` scan. Optional
* existing imports without it fall back to the in-init scan path.
*/
idxOffsets?: string;
/** Same idea for `.syn`. */
synOffsets?: string;
mdx?: string;
mdd?: string[];
};
/** Source language code if known. */
lang?: string;
/** Wall-clock time of import (ms since epoch). */
addedAt: number;
/** Soft-delete marker; `undefined` while available. */
deletedAt?: number;
/**
* True when metadata is present (e.g. synced from another device) but the
* binary bundle is missing on this device. The settings UI surfaces a
* "Re-import" affordance; the popup hides the provider.
*/
unavailable?: boolean;
/**
* True when the bundle imports cleanly but the dictionary format is outside
* v1 scope (e.g. multi-type StarDict, raw `.dict` instead of `.dict.dz`,
* encrypted MDX). Provider returns `{ ok: false, reason: 'unsupported' }`.
*/
unsupported?: boolean;
/** Human-readable reason when `unsupported` is true. */
unsupportedReason?: string;
}
export interface DictionarySettings {
/** Provider id order shown in the popup tab strip. Includes builtin ids. */
providerOrder: string[];
/** Per-id enable flag. Builtins seeded `true`. */
providerEnabled: Record<string, boolean>;
/** Last-used tab id; `undefined` falls back to first enabled provider. */
defaultProviderId?: string;
}
/** Stable ids for the built-in providers. */
export const BUILTIN_PROVIDER_IDS = {
wiktionary: 'builtin:wiktionary',
wikipedia: 'builtin:wikipedia',
} as const;
export type BuiltinProviderId = (typeof BUILTIN_PROVIDER_IDS)[keyof typeof BUILTIN_PROVIDER_IDS];
@@ -47,6 +47,7 @@ import { SchemaType } from '@/services/database/migrate';
import {
DATA_SUBDIR,
LOCAL_BOOKS_SUBDIR,
LOCAL_DICTIONARIES_SUBDIR,
LOCAL_FONTS_SUBDIR,
LOCAL_IMAGES_SUBDIR,
SETTINGS_FILENAME,
@@ -92,7 +93,7 @@ const getPathResolver = ({
const getCustomBasePrefixSync = isCustomBaseDir
? (baseDir: BaseDir) => {
return () => {
const dataDirs = ['Settings', 'Data', 'Books', 'Fonts', 'Images'];
const dataDirs = ['Settings', 'Data', 'Books', 'Fonts', 'Images', 'Dictionaries'];
const leafDir = dataDirs.includes(baseDir) ? '' : baseDir;
return leafDir ? `${customRootDir}/${leafDir}` : customRootDir!;
};
@@ -164,6 +165,15 @@ const getPathResolver = ({
: `${LOCAL_IMAGES_SUBDIR}${path ? `/${path}` : ''}`,
base,
};
case 'Dictionaries':
return {
baseDir: customBaseDir ?? BaseDirectory.AppData,
basePrefix: customBasePrefix || appDataDir,
fp: customBasePrefixSync
? `${customBasePrefixSync()}/${LOCAL_DICTIONARIES_SUBDIR}${path ? `/${path}` : ''}`
: `${LOCAL_DICTIONARIES_SUBDIR}${path ? `/${path}` : ''}`,
base,
};
case 'None':
return {
baseDir: 0,
@@ -11,6 +11,7 @@ import { BaseAppService } from './appService';
import {
DATA_SUBDIR,
LOCAL_BOOKS_SUBDIR,
LOCAL_DICTIONARIES_SUBDIR,
LOCAL_FONTS_SUBDIR,
LOCAL_IMAGES_SUBDIR,
} from './constants';
@@ -99,7 +100,14 @@ const getPathResolver = ({ customRootDir }: { customRootDir?: string } = {}) =>
const isCustomBaseDir = Boolean(customRootDir);
const getCustomBasePrefix = isCustomBaseDir
? (base: BaseDir) => {
const dataDirs: BaseDir[] = ['Settings', 'Data', 'Books', 'Fonts', 'Images'];
const dataDirs: BaseDir[] = [
'Settings',
'Data',
'Books',
'Fonts',
'Images',
'Dictionaries',
];
const leafDir = dataDirs.includes(base) ? '' : base;
return leafDir ? `${customRootDir}/${leafDir}` : customRootDir!;
}
@@ -165,6 +173,15 @@ const getPathResolver = ({ customRootDir }: { customRootDir?: string } = {}) =>
: `${LOCAL_IMAGES_SUBDIR}${fp ? `/${fp}` : ''}`,
base,
};
case 'Dictionaries':
return {
baseDir: 0,
basePrefix: async () => custom ?? getAppDataDir(),
fp: custom
? `${custom}/${LOCAL_DICTIONARIES_SUBDIR}${fp ? `/${fp}` : ''}`
: `${LOCAL_DICTIONARIES_SUBDIR}${fp ? `/${fp}` : ''}`,
base,
};
case 'None':
return {
baseDir: 0,
@@ -148,6 +148,14 @@ export async function loadSettings(ctx: Context): Promise<SystemSettings> {
settings.localBooksDir = await ctx.fs.getPrefix('Books');
// Coerce stale `'wikipedia'` quick-action to `'dictionary'`. The Wikipedia
// annotation tool was removed; Wikipedia is now reachable as a tab inside
// the unified dictionary popup. Without this guard, users who had set the
// quick action to wikipedia would get a no-op.
if ((settings.globalViewSettings.annotationQuickAction as string) === 'wikipedia') {
settings.globalViewSettings.annotationQuickAction = 'dictionary';
}
if (!settings.kosync.deviceId) {
settings.kosync.deviceId = uuidv4();
await saveSettings(ctx.fs, settings);
@@ -9,6 +9,7 @@ import { BaseAppService } from './appService';
import {
DATA_SUBDIR,
LOCAL_BOOKS_SUBDIR,
LOCAL_DICTIONARIES_SUBDIR,
LOCAL_FONTS_SUBDIR,
LOCAL_IMAGES_SUBDIR,
} from './constants';
@@ -25,6 +26,8 @@ const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
return { baseDir: 0, basePrefix, fp: `${LOCAL_FONTS_SUBDIR}/${path}`, base };
case 'Images':
return { baseDir: 0, basePrefix, fp: `${LOCAL_IMAGES_SUBDIR}/${path}`, base };
case 'Dictionaries':
return { baseDir: 0, basePrefix, fp: `${LOCAL_DICTIONARIES_SUBDIR}/${path}`, base };
case 'None':
return { baseDir: 0, basePrefix, fp: path, base };
default:
@@ -0,0 +1,169 @@
import { create } from 'zustand';
import { EnvConfigType } from '@/services/environment';
import type { DictionarySettings, ImportedDictionary } from '@/services/dictionaries/types';
import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
import { useSettingsStore } from './settingsStore';
const DEFAULT_DICTIONARY_SETTINGS: DictionarySettings = {
providerOrder: [BUILTIN_PROVIDER_IDS.wiktionary, BUILTIN_PROVIDER_IDS.wikipedia],
providerEnabled: {
[BUILTIN_PROVIDER_IDS.wiktionary]: true,
[BUILTIN_PROVIDER_IDS.wikipedia]: true,
},
};
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;
/** 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 },
}));
},
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).
const settingsMerged: DictionarySettings = {
providerOrder: persistedSettings.providerOrder.length
? persistedSettings.providerOrder
: DEFAULT_DICTIONARY_SETTINGS.providerOrder,
providerEnabled: {
...DEFAULT_DICTIONARY_SETTINGS.providerEnabled,
...persistedSettings.providerEnabled,
},
defaultProviderId: persistedSettings.defaultProviderId,
};
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;
}
},
}));
-1
View File
@@ -4,7 +4,6 @@ export type AnnotationToolType =
| 'annotate'
| 'search'
| 'dictionary'
| 'wikipedia'
| 'translate'
| 'tts'
| 'proofread';
+3
View File
@@ -5,6 +5,7 @@ import { HighlightColor, HighlightStyle, UserHighlightColor, ViewSettings } from
import { OPDSCatalog } from './opds';
import type { AISettings } from '@/services/ai/types';
import type { NotebookTab } from '@/store/notebookStore';
import type { DictionarySettings, ImportedDictionary } from '@/services/dictionaries/types';
export type ThemeType = 'light' | 'dark' | 'auto';
export type LibraryViewModeType = 'grid' | 'list';
@@ -109,6 +110,8 @@ export interface SystemSettings {
libraryColumns: number;
customFonts: CustomFont[];
customTextures: CustomTexture[];
customDictionaries: ImportedDictionary[];
dictionarySettings: DictionarySettings;
opdsCatalogs: OPDSCatalog[];
metadataSeriesCollapsed: boolean;
metadataOthersCollapsed: boolean;
+6 -1
View File
@@ -7,11 +7,14 @@ import { CustomFont, CustomFontInfo } from '@/styles/fonts';
import { CustomTextureInfo } from '@/styles/textures';
import { DatabaseOpts, DatabaseService } from './database';
import { SchemaType } from '@/services/database/migrate';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { ImportDictionariesResult } from '@/services/dictionaries/dictionaryService';
import type { SelectedFile } from '@/hooks/useFileSelector';
export type AppPlatform = 'web' | 'tauri' | 'node';
export type OsPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown';
// prettier-ignore
export type BaseDir = | 'Books' | 'Settings' | 'Data' | 'Fonts' | 'Images' | 'Log' | 'Cache' | 'Temp' | 'None';
export type BaseDir = | 'Books' | 'Settings' | 'Data' | 'Fonts' | 'Images' | 'Dictionaries' | 'Log' | 'Cache' | 'Temp' | 'None';
export type DeleteAction = 'cloud' | 'local' | 'both';
export type SelectDirectoryMode = 'read' | 'write';
export type DistChannel = 'readest' | 'playstore' | 'appstore' | 'unknown';
@@ -127,6 +130,8 @@ export interface AppService {
deleteFont(font: CustomFont): Promise<void>;
importImage(file?: string | File): Promise<CustomTextureInfo | null>;
deleteImage(texture: CustomTextureInfo): Promise<void>;
importDictionaries(files: SelectedFile[]): Promise<ImportDictionariesResult>;
deleteDictionary(dict: ImportedDictionary): Promise<void>;
importBook(file: string | File, books: Book[], options?: ImportBookOptions): Promise<Book | null>;
refreshBookMetadata(book: Book): Promise<boolean>;
deleteBook(book: Book, deleteAction: DeleteAction): Promise<void>;
+2
View File
@@ -25,6 +25,8 @@
"@/components/ui/*": ["./src/components/primitives/*"],
"@pdfjs/*": ["./public/vendor/pdfjs/*"],
"@simplecc/*": ["./public/vendor/simplecc/*"],
"js-mdict": ["../../packages/js-mdict/src/index.ts"],
"fflate": ["./node_modules/fflate"],
"tauri-plugin-turso": ["./src-tauri/plugins/tauri-plugin-turso/guest-js"]
}
},
+7
View File
@@ -11,6 +11,13 @@ export default defineConfig({
// source files. foliate-js/pdf.js lives outside that scope, so Vite
// needs an explicit alias to find the vendored pdfjs build.
'@pdfjs': path.resolve(__dirname, 'public/vendor/pdfjs'),
// `js-mdict` is consumed via tsconfig paths from `packages/js-mdict/src/`.
// Its sources `import 'fflate'` directly — without an alias, vite's
// import-analysis walks up from the redirected file location and fails
// to find fflate (it's installed only in this app's node_modules).
// Pin all `fflate` resolutions to the app's copy to keep js-mdict
// self-contained at the source-tree level.
fflate: path.resolve(__dirname, 'node_modules/fflate'),
},
},
test: {
+1
Submodule packages/js-mdict added at 3355568e44
+65 -6
View File
@@ -87,6 +87,15 @@ importers:
'@choochmeque/tauri-plugin-sharekit-api':
specifier: ^0.3.0
version: 0.3.0
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.5)
'@emnapi/core':
specifier: ^1.7.1
version: 1.8.1
@@ -243,6 +252,9 @@ importers:
dompurify:
specifier: '>=3.4.0'
version: 3.4.1
fflate:
specifier: ^0.8.2
version: 0.8.2
foliate-js:
specifier: workspace:*
version: link:../../packages/foliate-js
@@ -1243,6 +1255,28 @@ packages:
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@dotenvx/dotenvx@1.31.0':
resolution: {integrity: sha512-GeDxvtjiRuoyWVU9nQneId879zIyNdL05bS7RKiqMkfBSKpHMWHLoRyRqjYWLaXmX/llKO1hTlqHDmatkQAjPA==}
hasBin: true
@@ -10022,6 +10056,31 @@ snapshots:
'@discoveryjs/json-ext@0.5.7': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.5)':
dependencies:
react: 19.2.5
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.5)
'@dnd-kit/utilities': 3.2.2(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@dnd-kit/utilities': 3.2.2(react@19.2.5)
react: 19.2.5
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.5)':
dependencies:
react: 19.2.5
tslib: 2.8.1
'@dotenvx/dotenvx@1.31.0':
dependencies:
commander: 11.1.0
@@ -10478,7 +10537,7 @@ snapshots:
'@jest/pattern@30.0.1':
dependencies:
'@types/node': 22.19.7
'@types/node': 22.19.17
jest-regex-util: 30.0.1
'@jest/schemas@30.0.5':
@@ -10491,7 +10550,7 @@ snapshots:
'@jest/schemas': 30.0.5
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 22.19.7
'@types/node': 22.19.17
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -12539,7 +12598,7 @@ snapshots:
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 22.19.7
'@types/node': 22.19.17
optional: true
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260312.1':
@@ -15149,7 +15208,7 @@ snapshots:
jest-mock@30.2.0:
dependencies:
'@jest/types': 30.2.0
'@types/node': 22.19.7
'@types/node': 22.19.17
jest-util: 30.2.0
jest-regex-util@30.0.1: {}
@@ -15157,7 +15216,7 @@ snapshots:
jest-util@30.2.0:
dependencies:
'@jest/types': 30.2.0
'@types/node': 22.19.7
'@types/node': 22.19.17
chalk: 4.1.2
ci-info: 4.4.0
graceful-fs: 4.2.11
@@ -16538,7 +16597,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 22.19.7
'@types/node': 22.19.17
long: 5.3.2
proxy-addr@2.0.7: