diff --git a/.gitmodules b/.gitmodules index 45ff1fc0..9f34582b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/apps/readest-app/.gitignore b/apps/readest-app/.gitignore index 40b75e84..d7aa7c48 100644 --- a/apps/readest-app/.gitignore +++ b/apps/readest-app/.gitignore @@ -67,6 +67,7 @@ src-tauri/gen /dist/ +.context/ .claude/settings.local.json .claude/skills diff --git a/apps/readest-app/next.config.mjs b/apps/readest-app/next.config.mjs index 89f7b877..a2cd6bb3 100644 --- a/apps/readest-app/next.config.mjs +++ b/apps/readest-app/next.config.mjs @@ -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' } : {}), }, }, diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 53906c68..d8c468d0 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -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", diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 5c8d3b1e..21dc1cf8 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -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 →": "اقرأ على ويكيبيديا ←" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index fb5a80b7..6053cf4a 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -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 →": "উইকিপিডিয়ায় পড়ুন →" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 61feed7a..fae6ef9c 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -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 →": "ཝི་ཁི་ཕིཌིཡ་ནས་ཀློག →" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 8b606b8a..8b2339e7 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 71db4ce9..19e18692 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 59a823a3..634d1f9d 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 2219d792..e12d880b 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -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 →": "در ویکی‌پدیا بخوانید →" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 5347a906..38f0be1d 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -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 n’est 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 l’utiliser.", + "This dictionary format is not supported.": "Ce format de dictionnaire n’est 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 l’import 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 l’import.", + "Manage Dictionaries": "Gérer les dictionnaires", + "Select Dictionary Files": "Sélectionner les fichiers du dictionnaire", + "Read on Wikipedia →": "Lire sur Wikipédia →" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 413eb2dd..fd5665ef 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -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 →": "קראו בוויקיפדיה →" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 8b7a80a8..52f7c65b 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -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 →": "विकिपीडिया पर पढ़ें →" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 3a49612b..709b932d 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 046dfcfe..e65e5693 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 56371c84..9372097d 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -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 l’importazione.", + "Manage Dictionaries": "Gestisci dizionari", + "Select Dictionary Files": "Seleziona file del dizionario", + "Read on Wikipedia →": "Leggi su Wikipedia →" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 4a07f949..e9cabaf1 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -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 で読む →" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index b745aeb7..9f5e406d 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -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 →": "위키백과에서 읽기 →" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index b932d2b3..9f8df6b9 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 4d421822..8b4acf64 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 523bcac7..300f78d6 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 69523430..1c2dff9a 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index f6a9d124..1534bad7 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 06fd9c37..3235a647 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -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 →": "Читать в Википедии →" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 6bed0b9d..3a29f481 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -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 හි කියවන්න →" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 4f7cf6d7..3ad3e0e2 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 323304e9..d9aea334 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index d0cc4e4d..bf569bd7 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -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 →": "விக்கிப்பீடியாவில் படிக்க →" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 778dcb1d..478464f5 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -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 →": "อ่านบนวิกิพีเดีย →" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 6404267a..15f260f8 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 91549651..35dadc84 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -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 →": "Читати у Вікіпедії →" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 05aeea7e..1c3e8cbd 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -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 →" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 6d93ef22..58e0dc1d 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -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 →": "在维基百科上阅读 →" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 57664c4f..3bcb57b5 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -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 →": "在維基百科上閱讀 →" } diff --git a/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-10-colors-chromium.png b/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-10-colors-chromium.png index 9425895f..1f3096cf 100644 Binary files a/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-10-colors-chromium.png and b/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-10-colors-chromium.png differ diff --git a/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-15-colors-chromium.png b/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-15-colors-chromium.png index 9425895f..1f3096cf 100644 Binary files a/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-15-colors-chromium.png and b/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-15-colors-chromium.png differ diff --git a/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-5-colors-chromium.png b/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-5-colors-chromium.png index 321a52a3..73134bee 100644 Binary files a/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-5-colors-chromium.png and b/apps/readest-app/src/__tests__/components/__screenshots__/annotation-popup-layout.browser.test.tsx/annotation-popup-5-colors-chromium.png differ diff --git a/apps/readest-app/src/__tests__/fixtures/data/dicts/cmudict.dict.dz b/apps/readest-app/src/__tests__/fixtures/data/dicts/cmudict.dict.dz new file mode 100644 index 00000000..6b773637 Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/dicts/cmudict.dict.dz differ diff --git a/apps/readest-app/src/__tests__/fixtures/data/dicts/cmudict.idx b/apps/readest-app/src/__tests__/fixtures/data/dicts/cmudict.idx new file mode 100644 index 00000000..f81d3118 Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/dicts/cmudict.idx differ diff --git a/apps/readest-app/src/__tests__/fixtures/data/dicts/cmudict.ifo b/apps/readest-app/src/__tests__/fixtures/data/dicts/cmudict.ifo new file mode 100644 index 00000000..da6661c2 --- /dev/null +++ b/apps/readest-app/src/__tests__/fixtures/data/dicts/cmudict.ifo @@ -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 diff --git a/apps/readest-app/src/__tests__/fixtures/data/dicts/eng-nld.dict.dz b/apps/readest-app/src/__tests__/fixtures/data/dicts/eng-nld.dict.dz new file mode 100644 index 00000000..be6488de Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/dicts/eng-nld.dict.dz differ diff --git a/apps/readest-app/src/__tests__/fixtures/data/dicts/eng-nld.idx b/apps/readest-app/src/__tests__/fixtures/data/dicts/eng-nld.idx new file mode 100644 index 00000000..f45b3f5f Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/dicts/eng-nld.idx differ diff --git a/apps/readest-app/src/__tests__/fixtures/data/dicts/eng-nld.ifo b/apps/readest-app/src/__tests__/fixtures/data/dicts/eng-nld.ifo new file mode 100644 index 00000000..de450d72 --- /dev/null +++ b/apps/readest-app/src/__tests__/fixtures/data/dicts/eng-nld.ifo @@ -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
Copyright (C) 1999-2017 by various authors listed below.
Available under the terms of the GNU General Public License ver. 2.0 and any later version.
This Database was generated from ergane, http://www.travlang.com.
This dictionary comes to you through nice people making it available for free and for
good. It is part of the FreeDict project, http://freedict.org/. This project aims to make translating
dictionaries available for free. Your contributions are welcome! diff --git a/apps/readest-app/src/__tests__/fixtures/data/dicts/mdict-en-en.mdd b/apps/readest-app/src/__tests__/fixtures/data/dicts/mdict-en-en.mdd new file mode 100644 index 00000000..12912810 Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/dicts/mdict-en-en.mdd differ diff --git a/apps/readest-app/src/__tests__/fixtures/data/dicts/mdict-en-en.mdx b/apps/readest-app/src/__tests__/fixtures/data/dicts/mdict-en-en.mdx new file mode 100644 index 00000000..812439ad Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/dicts/mdict-en-en.mdx differ diff --git a/apps/readest-app/src/__tests__/helpers/shortcuts.test.ts b/apps/readest-app/src/__tests__/helpers/shortcuts.test.ts index f2c969c0..f5d0cedc 100644 --- a/apps/readest-app/src/__tests__/helpers/shortcuts.test.ts +++ b/apps/readest-app/src/__tests__/helpers/shortcuts.test.ts @@ -63,7 +63,6 @@ describe('No identical keybinding lists across actions (#3675)', () => { // class of bug as #3675. const KNOWN_PAIRS: ReadonlySet = 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 () => { diff --git a/apps/readest-app/src/__tests__/services/dictionaries/_countingFs.ts b/apps/readest-app/src/__tests__/services/dictionaries/_countingFs.ts new file mode 100644 index 00000000..75295178 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/_countingFs.ts @@ -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; +} + +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 { + const buf = await this.inner.arrayBuffer(); + bump(this.counter, this.name, buf.byteLength); + return buf; + } + + override async text(): Promise { + 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 { + const buf = await this.inner.arrayBuffer(); + bump(this.counter, this.name, buf.byteLength); + return buf; + } + + override async text(): Promise { + 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); + }, + }; +} diff --git a/apps/readest-app/src/__tests__/services/dictionaries/_mdictFixtures.ts b/apps/readest-app/src/__tests__/services/dictionaries/_mdictFixtures.ts new file mode 100644 index 00000000..bccaa076 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/_mdictFixtures.ts @@ -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 { + 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 { + const bytes = await readFile(MDD_FIXTURE_PATH); + const buf = new Uint8Array(bytes.length); + buf.set(bytes); + return new File([buf], MDD_FIXTURE_NAME); +} diff --git a/apps/readest-app/src/__tests__/services/dictionaries/_stardictFixtures.ts b/apps/readest-app/src/__tests__/services/dictionaries/_stardictFixtures.ts new file mode 100644 index 00000000..93eba394 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/_stardictFixtures.ts @@ -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 { + 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); diff --git a/apps/readest-app/src/__tests__/services/dictionaries/mdict-init-diagnostic.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/mdict-init-diagnostic.test.ts new file mode 100644 index 00000000..70f7f856 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/mdict-init-diagnostic.test.ts @@ -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 { + 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'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/dictionaries/mdictProvider.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/mdictProvider.test.ts new file mode 100644 index 00000000..f90720aa --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/mdictProvider.test.ts @@ -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 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 . + 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); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/dictionaries/registry.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/registry.test.ts new file mode 100644 index 00000000..43513c2d --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/registry.test.ts @@ -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', + ]); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/dictionaries/starDictProvider.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/starDictProvider.test.ts new file mode 100644 index 00000000..b42ff38c --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/starDictProvider.test.ts @@ -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 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 => { + 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); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/dictionaries/wikipediaProvider.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/wikipediaProvider.test.ts new file mode 100644 index 00000000..8dd1df0f --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/wikipediaProvider.test.ts @@ -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: '

Cats are small mammals.

', + thumbnail: { source: 'https://example/cat.jpg' }, + dir: 'ltr', +}; + +describe('wikipedia provider', () => { + let fetchMock: ReturnType; + + 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('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('a[target="_blank"]'); + expect(link).toBeTruthy(); + expect(link!.href).toBe('https://fr.wikipedia.org/wiki/Cat'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/dictionaries/wiktionaryProvider.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/wiktionaryProvider.test.ts new file mode 100644 index 00000000..fe8aca8a --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/wiktionaryProvider.test.ts @@ -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 cat is a small animal.', + examples: ['The cat sat on the mat.'], + }, + ], + }, + ], +}; + +describe('wiktionary provider', () => { + let fetchMock: ReturnType; + + 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('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'); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/jsmdict-resolve.test.ts b/apps/readest-app/src/__tests__/utils/jsmdict-resolve.test.ts new file mode 100644 index 00000000..8f88e29e --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/jsmdict-resolve.test.ts @@ -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); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/annotator/AnnotationTools.tsx b/apps/readest-app/src/app/reader/components/annotator/AnnotationTools.tsx index 72966e9a..2e477668 100644 --- a/apps/readest-app/src/app/reader/components/annotator/AnnotationTools.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/AnnotationTools.tsx @@ -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'), diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 234f3fbb..0cb8dabe 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -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(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(); @@ -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 (
- {showWiktionaryPopup && trianglePosition && dictPopupPosition && ( - = ({ bookKey }) => { onDismiss={handleDismissPopupAndSelection} /> )} - {showWikipediaPopup && trianglePosition && dictPopupPosition && ( - - )} {showDeepLPopup && trianglePosition && translatorPopupPosition && ( 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 = ({ + 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(() => 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(initialTab); + const [tabStates, setTabStates] = useState>(() => { + const seed: Record = {}; + 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 = {}; + 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 | 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>(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 `` 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 ( + +
+ {providers.length > 1 && ( +
+ {providers.map((p) => { + const isActive = p.id === activeTab; + return ( + + ); + })} +
+ )} + + {providers.length === 0 ? ( +
+

{_('No dictionaries enabled')}

+

+ {_('Enable a dictionary in Settings → Language → Dictionaries.')} +

+
+ ) : ( + providers.map((p) => { + const isActive = p.id === activeTab; + const state = tabStates[p.id]?.state ?? 'idle'; + const showBack = isActive && canGoBack; + return ( + + + ); +}; + +const renderErrorPlaceholder = ( + container: HTMLElement, + word: string, + outcome: DictionaryLookupOutcome, + _: (key: string, opts?: Record) => 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: `
${word}`, + }); + } 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; diff --git a/apps/readest-app/src/app/reader/components/annotator/WikipediaPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/WikipediaPopup.tsx deleted file mode 100644 index 1f5fa949..00000000 --- a/apps/readest-app/src/app/reader/components/annotator/WikipediaPopup.tsx +++ /dev/null @@ -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 = ({ - 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: `Wikipedia`, - }); - - 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 ( -
- -
-
-
-
- Source: Wikipedia (CC BY-SA) -
-
-
-
-
- ); -}; - -export default WikipediaPopup; diff --git a/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx deleted file mode 100644 index 92c1710e..00000000 --- a/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx +++ /dev/null @@ -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 = ({ - 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(null); - const footerRef = useRef(null); - const lastScrollTopRef = useRef(0); - const lastDirectionRef = useRef<'up' | 'down' | null>(null); - const scrollDeltaRef = useRef(0); - const rafRef = useRef(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('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: `Wiktionary`, - }); - - 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 ( -
- -
- {canGoBack && ( - - )} -
-
-
- Source: Wiktionary (CC BY-SA) -
-
-
-
-
- ); -}; - -export default WiktionaryPopup; diff --git a/apps/readest-app/src/components/settings/CustomDictionaries.tsx b/apps/readest-app/src/components/settings/CustomDictionaries.tsx new file mode 100644 index 00000000..f3d80e09 --- /dev/null +++ b/apps/readest-app/src/components/settings/CustomDictionaries.tsx @@ -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; +} + +const SortableRow: React.FC = ({ + 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 ( +
+ {/* 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. */} + + +
+
+ + {row.label} + + {row.badge} +
+ {row.reason && ( +
+ + {row.reason} +
+ )} +
+ + 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 && ( + + )} +
+ ); +}; + +const CustomDictionaries: React.FC = ({ 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 ( +
+
+
+
    +
  • + +
  • +
  • {_('Dictionaries')}
  • +
+
+ {hasImported && ( + + )} +
+ +
+ +
+ +
+
+ {rows.length === 0 && ( +
+ {_('No dictionaries available.')} +
+ )} + + r.id)} strategy={verticalListSortingStrategy}> + {rows.map((row) => ( + + ))} + + +
+
+ +
+
+
{_('Tips')}:
+
    +
  • {_('Drag the handle on the left to reorder.')}
  • +
  • {_('StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).')}
  • +
  • {_('MDict bundles use .mdx files; companion .mdd files are optional.')}
  • +
  • {_('Select all the bundle files together when importing.')}
  • +
+
+
+
+ ); +}; + +export default CustomDictionaries; diff --git a/apps/readest-app/src/components/settings/LangPanel.tsx b/apps/readest-app/src/components/settings/LangPanel.tsx index 08e6d1dc..757189e8 100644 --- a/apps/readest-app/src/components/settings/LangPanel.tsx +++ b/apps/readest-app/src/components/settings/LangPanel.tsx @@ -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 = ({ bookKey, onRegisterReset }) => { const _ = useTranslation(); @@ -40,6 +42,7 @@ const LangPanel: React.FC = ({ bookKey, onRegisterReset const [convertChineseVariant, setConvertChineseVariant] = useState( viewSettings.convertChineseVariant, ); + const [showCustomDictionaries, setShowCustomDictionaries] = useState(false); const resetToDefaults = useResetViewSettings(); @@ -242,6 +245,14 @@ const LangPanel: React.FC = ({ bookKey, onRegisterReset // eslint-disable-next-line react-hooks/exhaustive-deps }, [convertChineseVariant]); + if (showCustomDictionaries) { + return ( +
+ setShowCustomDictionaries(false)} /> +
+ ); + } + return (
@@ -260,6 +271,22 @@ const LangPanel: React.FC = ({ bookKey, onRegisterReset
+
+

{_('Dictionaries')}

+
+
+ +
+
+
+

{_('Translation')}

diff --git a/apps/readest-app/src/helpers/shortcuts.ts b/apps/readest-app/src/helpers/shortcuts.ts index 0cd80f47..c0f82173 100644 --- a/apps/readest-app/src/helpers/shortcuts.ts +++ b/apps/readest-app/src/helpers/shortcuts.ts @@ -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'), diff --git a/apps/readest-app/src/hooks/useFileSelector.ts b/apps/readest-app/src/hooks/useFileSelector.ts index 11b1f067..dd77a8e1 100644 --- a/apps/readest-app/src/hooks/useFileSelector.ts +++ b/apps/readest-app/src/hooks/useFileSelector.ts @@ -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'], diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index c3a9a4b7..3ab0bff7 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -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 { + return DictSvc.importDictionaries(this.fs, files); + } + + async deleteDictionary(dict: ImportedDictionary): Promise { + return DictSvc.deleteDictionary(this.fs, dict); + } + async importBook( file: string | File, books: Book[], diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 52aeb557..4c80ba2e 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -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 = { 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, diff --git a/apps/readest-app/src/services/dictionaries/dictionaryService.ts b/apps/readest-app/src/services/dictionaries/dictionaryService.ts new file mode 100644 index 00000000..9692745d --- /dev/null +++ b/apps/readest-app/src/services/dictionaries/dictionaryService.ts @@ -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'//`, 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 { + 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(); + 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 { + const out: Record = {}; + 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 { + 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 { + const dst = `${bundleDir}/${filename}`; + await fs.writeFile(dst, 'Dictionaries', source); +} + +/** Build a fresh bundle directory `'Dictionaries'//`. */ +async function createBundleDir(fs: FileSystem): Promise { + const id = uniqueId(); + await fs.createDir(id, 'Dictionaries', true); + return id; +} + +async function importStarDictBundle( + fs: FileSystem, + group: StarDictGroup, +): Promise { + 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 { + 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; + 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 { + 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 { + if (await fs.exists(dict.bundleDir, 'Dictionaries')) { + await fs.removeDir(dict.bundleDir, 'Dictionaries', true); + } +} diff --git a/apps/readest-app/src/services/dictionaries/providers/mdictProvider.ts b/apps/readest-app/src/services/dictionaries/providers/mdictProvider.ts new file mode 100644 index 00000000..75d68770 --- /dev/null +++ b/apps/readest-app/src/services/dictionaries/providers/mdictProvider.ts @@ -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 + * ``, 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; +} + +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 `` 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 { + if (!mdds.length) return; + const imgs = Array.from(container.querySelectorAll('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 | null = null; + let initError: Error | null = null; + const trackedUrls: string[] = []; + + const initOnce = async (): Promise => { + if (mdx) return; + if (initError) throw initError; + if (!initPromise) { + initPromise = (async () => { + const { MDX, MDD } = (await import('js-mdict')) as { + MDX: { create(file: Blob): Promise }; + MDD: { create(file: Blob): Promise }; + }; + + 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; + }, + }; +}; diff --git a/apps/readest-app/src/services/dictionaries/providers/starDictProvider.ts b/apps/readest-app/src/services/dictionaries/providers/starDictProvider.ts new file mode 100644 index 00000000..dacb39ea --- /dev/null +++ b/apps/readest-app/src/services/dictionaries/providers/starDictProvider.ts @@ -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; +} + +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 | null = null; + let initError: Error | null = null; + + const initOnce = async (): Promise => { + 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, + }; + } + }, + }; +}; diff --git a/apps/readest-app/src/services/dictionaries/providers/wikipediaProvider.ts b/apps/readest-app/src/services/dictionaries/providers/wikipediaProvider.ts new file mode 100644 index 00000000..6f319e32 --- /dev/null +++ b/apps/readest-app/src/services/dictionaries/providers/wikipediaProvider.ts @@ -0,0 +1,111 @@ +/** + * Built-in Wikipedia provider. + * + * Looks up the selection text in `.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 { + 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), + }; + } + }, +}; diff --git a/apps/readest-app/src/services/dictionaries/providers/wiktionaryProvider.ts b/apps/readest-app/src/services/dictionaries/providers/wiktionaryProvider.ts new file mode 100644 index 00000000..b7317def --- /dev/null +++ b/apps/readest-app/src/services/dictionaries/providers/wiktionaryProvider.ts @@ -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 `
` 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('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 => { + 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 => { + 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), + }; + } + }, +}; diff --git a/apps/readest-app/src/services/dictionaries/registry.ts b/apps/readest-app/src/services/dictionaries/registry.ts new file mode 100644 index 00000000..1d8e18fd --- /dev/null +++ b/apps/readest-app/src/services/dictionaries/registry.ts @@ -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(); + +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(); +}; diff --git a/apps/readest-app/src/services/dictionaries/stardictReader.ts b/apps/readest-app/src/services/dictionaries/stardictReader.ts new file mode 100644 index 00000000..978c472a --- /dev/null +++ b/apps/readest-app/src/services/dictionaries/stardictReader.ts @@ -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: + * `\0` + * (`.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 { + const out: Record = {}; + 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: `\0`. 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 { + private readonly max: number; + private readonly map = new Map(); + + 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; + + constructor(blob: Blob, meta: DictZipMeta, cacheSize: number) { + this.blob = blob; + this.meta = meta; + this.chunkCache = new LRU(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 { + 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 { + 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 = {}; + + // 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 | null = null; + + // LRU caches — keyed by entry index within their respective offset arrays. + private idxCache: LRU; + private synCache: LRU; + + constructor(cacheSize = 256) { + this.idxCache = new LRU(cacheSize); + this.synCache = new LRU(cacheSize); + } + + async load(opts: StarDictReaderOpts): Promise { + const cacheSize = opts.cacheSize ?? 256; + if (opts.cacheSize !== undefined) { + this.idxCache = new LRU(cacheSize); + this.synCache = new LRU(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/readest-app/src/services/dictionaries/types.ts b/apps/readest-app/src/services/dictionaries/types.ts new file mode 100644 index 00000000..87b9f5ac --- /dev/null +++ b/apps/readest-app/src/services/dictionaries/types.ts @@ -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; + /** Look up a word; populate `ctx.container`; return outcome. */ + lookup(word: string, ctx: DictionaryLookupContext): Promise; + /** 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'`//; 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; + /** 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]; diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index bbc05dfa..d37cd540 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -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, diff --git a/apps/readest-app/src/services/nodeAppService.ts b/apps/readest-app/src/services/nodeAppService.ts index 71a6a690..48d73929 100644 --- a/apps/readest-app/src/services/nodeAppService.ts +++ b/apps/readest-app/src/services/nodeAppService.ts @@ -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, diff --git a/apps/readest-app/src/services/settingsService.ts b/apps/readest-app/src/services/settingsService.ts index c3880513..99b3beeb 100644 --- a/apps/readest-app/src/services/settingsService.ts +++ b/apps/readest-app/src/services/settingsService.ts @@ -148,6 +148,14 @@ export async function loadSettings(ctx: Context): Promise { 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); diff --git a/apps/readest-app/src/services/webAppService.ts b/apps/readest-app/src/services/webAppService.ts index f5ec07e4..c482b023 100644 --- a/apps/readest-app/src/services/webAppService.ts +++ b/apps/readest-app/src/services/webAppService.ts @@ -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: diff --git a/apps/readest-app/src/store/customDictionaryStore.ts b/apps/readest-app/src/store/customDictionaryStore.ts new file mode 100644 index 00000000..6a2f8f34 --- /dev/null +++ b/apps/readest-app/src/store/customDictionaryStore.ts @@ -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; + /** Persist current state back into settings (which then syncs to cloud). */ + saveCustomDictionaries(envConfig: EnvConfigType): Promise; +} + +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((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; + } + }, +})); diff --git a/apps/readest-app/src/types/annotator.ts b/apps/readest-app/src/types/annotator.ts index 911b68fe..da20a98a 100644 --- a/apps/readest-app/src/types/annotator.ts +++ b/apps/readest-app/src/types/annotator.ts @@ -4,7 +4,6 @@ export type AnnotationToolType = | 'annotate' | 'search' | 'dictionary' - | 'wikipedia' | 'translate' | 'tts' | 'proofread'; diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index b649a332..3b878d49 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -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; diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 0c35760d..34b66804 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -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; importImage(file?: string | File): Promise; deleteImage(texture: CustomTextureInfo): Promise; + importDictionaries(files: SelectedFile[]): Promise; + deleteDictionary(dict: ImportedDictionary): Promise; importBook(file: string | File, books: Book[], options?: ImportBookOptions): Promise; refreshBookMetadata(book: Book): Promise; deleteBook(book: Book, deleteAction: DeleteAction): Promise; diff --git a/apps/readest-app/tsconfig.json b/apps/readest-app/tsconfig.json index ee86145e..6ba54009 100644 --- a/apps/readest-app/tsconfig.json +++ b/apps/readest-app/tsconfig.json @@ -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"] } }, diff --git a/apps/readest-app/vitest.config.mts b/apps/readest-app/vitest.config.mts index 7fbaa3a1..c9621aae 100644 --- a/apps/readest-app/vitest.config.mts +++ b/apps/readest-app/vitest.config.mts @@ -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: { diff --git a/packages/js-mdict b/packages/js-mdict new file mode 160000 index 00000000..3355568e --- /dev/null +++ b/packages/js-mdict @@ -0,0 +1 @@ +Subproject commit 3355568e4439e3118701787384fe20ccbf420bba diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02c1044a..eb14e937 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: