From 93abca8960a4b5e4ead82f9a04026a0054c807c8 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 28 May 2026 13:48:16 +0800 Subject: [PATCH] feat(dict): faster MDict/StarDict import + lazy lookup; raw .dict; UX (#4334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the dictionary import path usable on large bundles and bring the multi-device flow up to par. Import perf - Skip `MDX.create()` at import time. The factory triggers full init — decompresses every key block and sorts millions of keys with localeCompare just to expose the header. Replace with a tiny `readMdxHeader()` that only reads the small XML header for Title / Encoding / Encrypted (saves ~17 s on a 250 MB MDX on web). - `partialMD5`: read the 9 sample slices in parallel rather than sequentially. Each freshly-picked-File slice round-trip on Chrome costs ~100 ms cold; parallelisation collapses 9 of them into one. - Native fast-path in `nativeAppService.writeFile`: when the source is a `NativeFile`, delegate to Tauri's `copyFile` rather than streaming the file through `NativeFile.stream()`. Streaming a 250 MB body through 1 MB IPC chunks on Android took ~100 s; native copy is bound by disk throughput instead. Exposes `NativeFile.getNativeLocation()` for the FS layer to use the underlying path + baseDir directly. Lazy lookup - Pass `lazy: true` to `MDX.create` / `MDD.create`. The js-mdict change in this PR skips the upfront decompress-every-block + sort during init (~80 s on the same 250 MB bundle) and decodes only the relevant key block on demand per lookup. First-lookup main-thread block drops from ~81 s to ~230 ms. (Closes #4228.) Raw .dict - Drop the import-time gate that flagged non-gzip dict bodies as `unsupported`. The runtime body loader (`loadDictBody`) already probes the gzip header and falls through to a passthrough buffer for raw files, so the gate was the only thing preventing raw `.dict` bundles from importing on devices that received them via cloud sync. (Closes #4179, partially addresses #4248.) Import-flow UX - `handleImport` now always surfaces a toast for every non-cancelled attempt: picker errors, missing app service, no-op imports, and unsupported-but-imported bundles each get their own message instead of failing silently. - Call `markAvailableByContentId(newDict.contentId)` after add/replace so the "Bundle is missing on this device" warning clears immediately — no need to close-and-reopen the panel. System Dictionary - Drop the cascading toggle behavior in `setEnabled`. Each provider's enabled flag persists independently; exclusivity is enforced at lookup time. Toggling System on/off no longer wipes the user's preferred set of in-app providers. - Render non-system rows as read-only when System is on (toggle still shows what's queued to restore; tooltip explains the lock). - `isSystemDictionaryEnabled` short-circuits to `false` on platforms where the handoff isn't implemented. `providerEnabled` is whole- field synced across devices, so a flag set on macOS would otherwise leak to a Windows device with no way to look up a word. js-mdict - Submodule bump to e6dbc99 which adds the opt-in `lazy: true` `MDictOptions` flag (skip `_readKeyBlocks` + post-init sort; new `lookupKeyBlockByWordLazy` path on `MDX` and `MDD`). Eager mode is unchanged and every existing js-mdict test still passes. i18n - 208 new translations across 33 locales for the new UX strings. Closes #4228 Closes #4248 Closes #4179 Co-authored-by: Claude Opus 4.7 (1M context) --- .../public/locales/ar/translation.json | 12 +- .../public/locales/bn/translation.json | 8 +- .../public/locales/bo/translation.json | 7 +- .../public/locales/de/translation.json | 8 +- .../public/locales/el/translation.json | 8 +- .../public/locales/es/translation.json | 9 +- .../public/locales/fa/translation.json | 8 +- .../public/locales/fr/translation.json | 9 +- .../public/locales/he/translation.json | 9 +- .../public/locales/hi/translation.json | 8 +- .../public/locales/hu/translation.json | 8 +- .../public/locales/id/translation.json | 7 +- .../public/locales/it/translation.json | 9 +- .../public/locales/ja/translation.json | 7 +- .../public/locales/ko/translation.json | 7 +- .../public/locales/ms/translation.json | 7 +- .../public/locales/nl/translation.json | 8 +- .../public/locales/pl/translation.json | 10 +- .../public/locales/pt-BR/translation.json | 9 +- .../public/locales/pt/translation.json | 9 +- .../public/locales/ro/translation.json | 9 +- .../public/locales/ru/translation.json | 10 +- .../public/locales/si/translation.json | 8 +- .../public/locales/sl/translation.json | 10 +- .../public/locales/sv/translation.json | 8 +- .../public/locales/ta/translation.json | 8 +- .../public/locales/th/translation.json | 7 +- .../public/locales/tr/translation.json | 8 +- .../public/locales/uk/translation.json | 10 +- .../public/locales/uz/translation.json | 8 +- .../public/locales/vi/translation.json | 7 +- .../public/locales/zh-CN/translation.json | 7 +- .../public/locales/zh-TW/translation.json | 7 +- .../importStardictRawDict.test.ts | 77 ++++++++++ .../settings/CustomDictionaries.tsx | 77 +++++++++- .../dictionaries/dictionaryService.ts | 140 ++++++++++-------- .../dictionaries/providers/mdictProvider.ts | 15 +- .../src/services/dictionaries/registry.ts | 9 ++ .../src/services/nativeAppService.ts | 16 ++ .../src/store/customDictionaryStore.ts | 39 ++--- apps/readest-app/src/utils/file.ts | 4 + apps/readest-app/src/utils/md5.ts | 18 +-- packages/js-mdict | 2 +- 43 files changed, 533 insertions(+), 138 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/dictionaries/importStardictRawDict.test.ts diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 14bff5f1..cc2bc42c 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1760,5 +1760,15 @@ "Send Reedy feedback": "إرسال ملاحظات Reedy", "OpenRouter API Key": "مفتاح OpenRouter API", "OpenRouter Base URL": "رابط OpenRouter الأساسي", - "OpenRouter Model": "نموذج OpenRouter" + "OpenRouter Model": "نموذج OpenRouter", + "Disable System Dictionary first to change this.": "قم بتعطيل قاموس النظام أولاً لتغيير هذا.", + "App service is not available": "خدمة التطبيق غير متوفرة", + "Unsupported dictionary: {{reason}}": "قاموس غير مدعوم: {{reason}}", + "{{count}} dictionary is unsupported and disabled_zero": "لا توجد قواميس غير مدعومة ومعطلة", + "{{count}} dictionary is unsupported and disabled_one": "قاموس واحد غير مدعوم ومعطل", + "{{count}} dictionary is unsupported and disabled_two": "قاموسان غير مدعومين ومعطلان", + "{{count}} dictionary is unsupported and disabled_few": "{{count}} قواميس غير مدعومة ومعطلة", + "{{count}} dictionary is unsupported and disabled_many": "{{count}} قاموساً غير مدعوماً ومعطلاً", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} قاموس غير مدعوم ومعطل", + "No new dictionaries were imported": "لم يتم استيراد أي قواميس جديدة" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 0d8b4b4a..21c4b679 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "Reedy ফিডব্যাক পাঠান", "OpenRouter API Key": "OpenRouter API কী", "OpenRouter Base URL": "OpenRouter বেস URL", - "OpenRouter Model": "OpenRouter মডেল" + "OpenRouter Model": "OpenRouter মডেল", + "Disable System Dictionary first to change this.": "এটি পরিবর্তন করতে প্রথমে সিস্টেম অভিধান নিষ্ক্রিয় করুন।", + "App service is not available": "অ্যাপ পরিষেবা উপলব্ধ নয়", + "Unsupported dictionary: {{reason}}": "অসমর্থিত অভিধান: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}}টি অভিধান অসমর্থিত এবং নিষ্ক্রিয়", + "{{count}} dictionary is unsupported and disabled_other": "{{count}}টি অভিধান অসমর্থিত এবং নিষ্ক্রিয়", + "No new dictionaries were imported": "কোনো নতুন অভিধান আমদানি করা হয়নি" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 39678c2a..1c267dd1 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1610,5 +1610,10 @@ "Send Reedy feedback": "Reedy ལ་བསམ་ཚུལ་གཏོང་", "OpenRouter API Key": "OpenRouter API ལྡེ་མིག", "OpenRouter Base URL": "OpenRouter རྩ་བའི་ URL", - "OpenRouter Model": "OpenRouter དཔེ་གཟུགས" + "OpenRouter Model": "OpenRouter དཔེ་གཟུགས", + "Disable System Dictionary first to change this.": "འདི་བརྗེ་བཅོས་བྱེད་པར་ཐོག་མར་མ་ལག་ཚིག་མཛོད་སྤོ་ཟུར།", + "App service is not available": "ཉེར་སྤྱོད་ཞབས་ཞུ་ཐོབ་མི་ཐུབ།", + "Unsupported dictionary: {{reason}}": "རྒྱབ་སྐྱོར་མེད་པའི་ཚིག་མཛོད། {{reason}}", + "{{count}} dictionary is unsupported and disabled_other": "ཚིག་མཛོད་ {{count}} ལ་རྒྱབ་སྐྱོར་མེད་ཅིང་སྤོ་ཟུར་བྱས་ཡོད།", + "No new dictionaries were imported": "ཚིག་མཛོད་གསར་པ་ནང་འདྲེན་མ་བྱུང་།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 813b1db0..3468b4a8 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "Reedy-Feedback senden", "OpenRouter API Key": "OpenRouter-API-Schlüssel", "OpenRouter Base URL": "OpenRouter-Basis-URL", - "OpenRouter Model": "OpenRouter-Modell" + "OpenRouter Model": "OpenRouter-Modell", + "Disable System Dictionary first to change this.": "Deaktivieren Sie zuerst das Systemwörterbuch, um dies zu ändern.", + "App service is not available": "App-Dienst ist nicht verfügbar", + "Unsupported dictionary: {{reason}}": "Nicht unterstütztes Wörterbuch: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} Wörterbuch wird nicht unterstützt und ist deaktiviert", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} Wörterbücher werden nicht unterstützt und sind deaktiviert", + "No new dictionaries were imported": "Keine neuen Wörterbücher importiert" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index bae7e361..b9ed667e 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "Αποστολή σχολίων Reedy", "OpenRouter API Key": "Κλειδί API OpenRouter", "OpenRouter Base URL": "Βασικό URL OpenRouter", - "OpenRouter Model": "Μοντέλο OpenRouter" + "OpenRouter Model": "Μοντέλο OpenRouter", + "Disable System Dictionary first to change this.": "Απενεργοποιήστε πρώτα το Λεξικό Συστήματος για να το αλλάξετε.", + "App service is not available": "Η υπηρεσία εφαρμογής δεν είναι διαθέσιμη", + "Unsupported dictionary: {{reason}}": "Μη υποστηριζόμενο λεξικό: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} λεξικό δεν υποστηρίζεται και είναι απενεργοποιημένο", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} λεξικά δεν υποστηρίζονται και είναι απενεργοποιημένα", + "No new dictionaries were imported": "Δεν εισήχθησαν νέα λεξικά" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 80c2eae0..2eb6bbb7 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1670,5 +1670,12 @@ "Send Reedy feedback": "Enviar comentarios sobre Reedy", "OpenRouter API Key": "Clave de API de OpenRouter", "OpenRouter Base URL": "URL base de OpenRouter", - "OpenRouter Model": "Modelo de OpenRouter" + "OpenRouter Model": "Modelo de OpenRouter", + "Disable System Dictionary first to change this.": "Desactiva primero el Diccionario del Sistema para cambiar esto.", + "App service is not available": "El servicio de la aplicación no está disponible", + "Unsupported dictionary: {{reason}}": "Diccionario no compatible: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} diccionario no es compatible y está deshabilitado", + "{{count}} dictionary is unsupported and disabled_many": "{{count}} diccionarios no son compatibles y están deshabilitados", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} diccionarios no son compatibles y están deshabilitados", + "No new dictionaries were imported": "No se importaron diccionarios nuevos" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index ed5beca2..b2cfbd1f 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "ارسال بازخورد Reedy", "OpenRouter API Key": "کلید API OpenRouter", "OpenRouter Base URL": "نشانی پایه OpenRouter", - "OpenRouter Model": "مدل OpenRouter" + "OpenRouter Model": "مدل OpenRouter", + "Disable System Dictionary first to change this.": "ابتدا فرهنگ لغت سیستم را غیرفعال کنید تا این را تغییر دهید.", + "App service is not available": "سرویس برنامه در دسترس نیست", + "Unsupported dictionary: {{reason}}": "فرهنگ لغت پشتیبانی‌نشده: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} فرهنگ لغت پشتیبانی نمی‌شود و غیرفعال است", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} فرهنگ لغت پشتیبانی نمی‌شوند و غیرفعال‌اند", + "No new dictionaries were imported": "هیچ فرهنگ لغت جدیدی وارد نشد" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 1b4d40a0..db8d2b17 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1670,5 +1670,12 @@ "Send Reedy feedback": "Envoyer un commentaire Reedy", "OpenRouter API Key": "Clé API OpenRouter", "OpenRouter Base URL": "URL de base OpenRouter", - "OpenRouter Model": "Modèle OpenRouter" + "OpenRouter Model": "Modèle OpenRouter", + "Disable System Dictionary first to change this.": "Désactivez d’abord le dictionnaire système pour modifier ceci.", + "App service is not available": "Le service de l’application n’est pas disponible", + "Unsupported dictionary: {{reason}}": "Dictionnaire non pris en charge : {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} dictionnaire n’est pas pris en charge et est désactivé", + "{{count}} dictionary is unsupported and disabled_many": "{{count}} dictionnaires ne sont pas pris en charge et sont désactivés", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} dictionnaires ne sont pas pris en charge et sont désactivés", + "No new dictionaries were imported": "Aucun nouveau dictionnaire n’a été importé" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 1fb74deb..95c964f1 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1670,5 +1670,12 @@ "Send Reedy feedback": "שלח משוב Reedy", "OpenRouter API Key": "מפתח API של OpenRouter", "OpenRouter Base URL": "כתובת בסיס OpenRouter", - "OpenRouter Model": "מודל OpenRouter" + "OpenRouter Model": "מודל OpenRouter", + "Disable System Dictionary first to change this.": "השבת תחילה את מילון המערכת כדי לשנות זאת.", + "App service is not available": "שירות האפליקציה אינו זמין", + "Unsupported dictionary: {{reason}}": "מילון לא נתמך: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} מילון אינו נתמך ומושבת", + "{{count}} dictionary is unsupported and disabled_two": "{{count}} מילונים אינם נתמכים ומושבתים", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} מילונים אינם נתמכים ומושבתים", + "No new dictionaries were imported": "לא יובאו מילונים חדשים" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index eb27d3c4..1f5570e9 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "Reedy फ़ीडबैक भेजें", "OpenRouter API Key": "OpenRouter API कुंजी", "OpenRouter Base URL": "OpenRouter बेस URL", - "OpenRouter Model": "OpenRouter मॉडल" + "OpenRouter Model": "OpenRouter मॉडल", + "Disable System Dictionary first to change this.": "इसे बदलने के लिए पहले सिस्टम शब्दकोश को अक्षम करें।", + "App service is not available": "ऐप सेवा उपलब्ध नहीं है", + "Unsupported dictionary: {{reason}}": "असमर्थित शब्दकोश: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} शब्दकोश असमर्थित और अक्षम है", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} शब्दकोश असमर्थित और अक्षम हैं", + "No new dictionaries were imported": "कोई नया शब्दकोश आयात नहीं किया गया" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 9173bc2f..100d57d5 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "Reedy visszajelzés küldése", "OpenRouter API Key": "OpenRouter API-kulcs", "OpenRouter Base URL": "OpenRouter alap URL", - "OpenRouter Model": "OpenRouter modell" + "OpenRouter Model": "OpenRouter modell", + "Disable System Dictionary first to change this.": "Először tiltsa le a rendszerszótárt a módosításhoz.", + "App service is not available": "Az alkalmazás szolgáltatása nem érhető el", + "Unsupported dictionary: {{reason}}": "Nem támogatott szótár: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} szótár nem támogatott, és le van tiltva", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} szótár nem támogatott, és le van tiltva", + "No new dictionaries were imported": "Nem importáltunk új szótárakat" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 871ab53e..f59b5a20 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1610,5 +1610,10 @@ "Send Reedy feedback": "Kirim umpan balik Reedy", "OpenRouter API Key": "Kunci API OpenRouter", "OpenRouter Base URL": "URL Dasar OpenRouter", - "OpenRouter Model": "Model OpenRouter" + "OpenRouter Model": "Model OpenRouter", + "Disable System Dictionary first to change this.": "Nonaktifkan Kamus Sistem terlebih dahulu untuk mengubah ini.", + "App service is not available": "Layanan aplikasi tidak tersedia", + "Unsupported dictionary: {{reason}}": "Kamus tidak didukung: {{reason}}", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} kamus tidak didukung dan dinonaktifkan", + "No new dictionaries were imported": "Tidak ada kamus baru yang diimpor" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 1a0e08be..038902d8 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1670,5 +1670,12 @@ "Send Reedy feedback": "Invia feedback Reedy", "OpenRouter API Key": "Chiave API OpenRouter", "OpenRouter Base URL": "URL di base OpenRouter", - "OpenRouter Model": "Modello OpenRouter" + "OpenRouter Model": "Modello OpenRouter", + "Disable System Dictionary first to change this.": "Disattiva prima il Dizionario di Sistema per modificare questa opzione.", + "App service is not available": "Il servizio dell’app non è disponibile", + "Unsupported dictionary: {{reason}}": "Dizionario non supportato: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} dizionario non è supportato ed è disattivato", + "{{count}} dictionary is unsupported and disabled_many": "{{count}} dizionari non sono supportati e sono disattivati", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} dizionari non sono supportati e sono disattivati", + "No new dictionaries were imported": "Nessun nuovo dizionario importato" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 13b0dfd7..c5baf3ce 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1610,5 +1610,10 @@ "Send Reedy feedback": "Reedy のフィードバックを送信", "OpenRouter API Key": "OpenRouter API キー", "OpenRouter Base URL": "OpenRouter ベース URL", - "OpenRouter Model": "OpenRouter モデル" + "OpenRouter Model": "OpenRouter モデル", + "Disable System Dictionary first to change this.": "変更するにはまずシステム辞書を無効にしてください。", + "App service is not available": "アプリサービスは利用できません", + "Unsupported dictionary: {{reason}}": "非対応の辞書:{{reason}}", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} 件の辞書が非対応のため無効になっています", + "No new dictionaries were imported": "新しい辞書は読み込まれませんでした" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 9f1aa0b3..090b8831 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1610,5 +1610,10 @@ "Send Reedy feedback": "Reedy 피드백 보내기", "OpenRouter API Key": "OpenRouter API 키", "OpenRouter Base URL": "OpenRouter 기본 URL", - "OpenRouter Model": "OpenRouter 모델" + "OpenRouter Model": "OpenRouter 모델", + "Disable System Dictionary first to change this.": "변경하려면 먼저 시스템 사전을 비활성화하세요.", + "App service is not available": "앱 서비스를 사용할 수 없습니다", + "Unsupported dictionary: {{reason}}": "지원되지 않는 사전: {{reason}}", + "{{count}} dictionary is unsupported and disabled_other": "{{count}}개의 사전이 지원되지 않아 비활성화되었습니다", + "No new dictionaries were imported": "가져온 새 사전이 없습니다" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index a138de54..827a5105 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1610,5 +1610,10 @@ "Send Reedy feedback": "Hantar maklum balas Reedy", "OpenRouter API Key": "Kunci API OpenRouter", "OpenRouter Base URL": "URL Asas OpenRouter", - "OpenRouter Model": "Model OpenRouter" + "OpenRouter Model": "Model OpenRouter", + "Disable System Dictionary first to change this.": "Lumpuhkan Kamus Sistem dahulu untuk mengubahnya.", + "App service is not available": "Perkhidmatan aplikasi tidak tersedia", + "Unsupported dictionary: {{reason}}": "Kamus tidak disokong: {{reason}}", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} kamus tidak disokong dan dilumpuhkan", + "No new dictionaries were imported": "Tiada kamus baharu diimport" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 7af0ce98..edb02fd3 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "Reedy-feedback verzenden", "OpenRouter API Key": "OpenRouter-API-sleutel", "OpenRouter Base URL": "OpenRouter-basis-URL", - "OpenRouter Model": "OpenRouter-model" + "OpenRouter Model": "OpenRouter-model", + "Disable System Dictionary first to change this.": "Schakel eerst het Systeemwoordenboek uit om dit te wijzigen.", + "App service is not available": "App-service is niet beschikbaar", + "Unsupported dictionary: {{reason}}": "Niet-ondersteund woordenboek: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} woordenboek wordt niet ondersteund en is uitgeschakeld", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} woordenboeken worden niet ondersteund en zijn uitgeschakeld", + "No new dictionaries were imported": "Er zijn geen nieuwe woordenboeken geïmporteerd" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 74cfdfe0..cfb8758a 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1700,5 +1700,13 @@ "Send Reedy feedback": "Wyślij opinię o Reedy", "OpenRouter API Key": "Klucz API OpenRouter", "OpenRouter Base URL": "Bazowy URL OpenRouter", - "OpenRouter Model": "Model OpenRouter" + "OpenRouter Model": "Model OpenRouter", + "Disable System Dictionary first to change this.": "Najpierw wyłącz Słownik systemowy, aby to zmienić.", + "App service is not available": "Usługa aplikacji jest niedostępna", + "Unsupported dictionary: {{reason}}": "Nieobsługiwany słownik: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} słownik jest nieobsługiwany i wyłączony", + "{{count}} dictionary is unsupported and disabled_few": "{{count}} słowniki są nieobsługiwane i wyłączone", + "{{count}} dictionary is unsupported and disabled_many": "{{count}} słowników jest nieobsługiwanych i wyłączonych", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} słowników jest nieobsługiwanych i wyłączonych", + "No new dictionaries were imported": "Nie zaimportowano żadnych nowych słowników" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index b03b68e0..ef710a0c 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1670,5 +1670,12 @@ "Send Reedy feedback": "Enviar feedback Reedy", "OpenRouter API Key": "Chave de API OpenRouter", "OpenRouter Base URL": "URL base OpenRouter", - "OpenRouter Model": "Modelo OpenRouter" + "OpenRouter Model": "Modelo OpenRouter", + "Disable System Dictionary first to change this.": "Desative primeiro o Dicionário do Sistema para alterar isto.", + "App service is not available": "O serviço do aplicativo não está disponível", + "Unsupported dictionary: {{reason}}": "Dicionário não suportado: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} dicionário não é suportado e está desativado", + "{{count}} dictionary is unsupported and disabled_many": "{{count}} dicionários não são suportados e estão desativados", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} dicionários não são suportados e estão desativados", + "No new dictionaries were imported": "Nenhum dicionário novo foi importado" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 28943e96..0802857c 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1670,5 +1670,12 @@ "Send Reedy feedback": "Enviar feedback Reedy", "OpenRouter API Key": "Chave de API OpenRouter", "OpenRouter Base URL": "URL base OpenRouter", - "OpenRouter Model": "Modelo OpenRouter" + "OpenRouter Model": "Modelo OpenRouter", + "Disable System Dictionary first to change this.": "Desative primeiro o Dicionário do Sistema para alterar isto.", + "App service is not available": "O serviço da aplicação não está disponível", + "Unsupported dictionary: {{reason}}": "Dicionário não suportado: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} dicionário não é suportado e está desativado", + "{{count}} dictionary is unsupported and disabled_many": "{{count}} dicionários não são suportados e estão desativados", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} dicionários não são suportados e estão desativados", + "No new dictionaries were imported": "Nenhum dicionário novo foi importado" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index dce093f2..7ff170d1 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1670,5 +1670,12 @@ "Send Reedy feedback": "Trimite feedback Reedy", "OpenRouter API Key": "Cheie API OpenRouter", "OpenRouter Base URL": "URL de bază OpenRouter", - "OpenRouter Model": "Model OpenRouter" + "OpenRouter Model": "Model OpenRouter", + "Disable System Dictionary first to change this.": "Dezactivați mai întâi Dicționarul de sistem pentru a modifica această opțiune.", + "App service is not available": "Serviciul aplicației nu este disponibil", + "Unsupported dictionary: {{reason}}": "Dicționar nesuportat: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} dicționar nu este acceptat și este dezactivat", + "{{count}} dictionary is unsupported and disabled_few": "{{count}} dicționare nu sunt acceptate și sunt dezactivate", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} de dicționare nu sunt acceptate și sunt dezactivate", + "No new dictionaries were imported": "Nu au fost importate dicționare noi" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 5ed9ec60..d25a23b2 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1700,5 +1700,13 @@ "Send Reedy feedback": "Отправить отзыв о Reedy", "OpenRouter API Key": "Ключ API OpenRouter", "OpenRouter Base URL": "Базовый URL OpenRouter", - "OpenRouter Model": "Модель OpenRouter" + "OpenRouter Model": "Модель OpenRouter", + "Disable System Dictionary first to change this.": "Сначала отключите системный словарь, чтобы изменить это.", + "App service is not available": "Служба приложения недоступна", + "Unsupported dictionary: {{reason}}": "Неподдерживаемый словарь: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} словарь не поддерживается и отключён", + "{{count}} dictionary is unsupported and disabled_few": "{{count}} словаря не поддерживаются и отключены", + "{{count}} dictionary is unsupported and disabled_many": "{{count}} словарей не поддерживаются и отключены", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} словарей не поддерживаются и отключены", + "No new dictionaries were imported": "Новые словари не были импортированы" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 8bb7dce0..5970acf5 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "Reedy ප්‍රතිපෝෂණ යවන්න", "OpenRouter API Key": "OpenRouter API යතුර", "OpenRouter Base URL": "OpenRouter මූල URL", - "OpenRouter Model": "OpenRouter ආකෘතිය" + "OpenRouter Model": "OpenRouter ආකෘතිය", + "Disable System Dictionary first to change this.": "මෙය වෙනස් කිරීමට පළමුව පද්ධති ශබ්දකෝෂය අක්‍රිය කරන්න.", + "App service is not available": "යෙදුම් සේවාව ලබා ගත නොහැක", + "Unsupported dictionary: {{reason}}": "සහාය නොදක්වන ශබ්දකෝෂය: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "ශබ්දකෝෂ {{count}}ක් සහාය නොදක්වන අතර අක්‍රිය කර ඇත", + "{{count}} dictionary is unsupported and disabled_other": "ශබ්දකෝෂ {{count}}ක් සහාය නොදක්වන අතර අක්‍රිය කර ඇත", + "No new dictionaries were imported": "නව ශබ්දකෝෂ ආයාත කර නැත" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index f557ee41..ddf8cfbc 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1700,5 +1700,13 @@ "Send Reedy feedback": "Pošlji povratne informacije Reedy", "OpenRouter API Key": "API-ključ OpenRouter", "OpenRouter Base URL": "Osnovni URL OpenRouter", - "OpenRouter Model": "Model OpenRouter" + "OpenRouter Model": "Model OpenRouter", + "Disable System Dictionary first to change this.": "Najprej onemogočite sistemski slovar, da to spremenite.", + "App service is not available": "Storitev aplikacije ni na voljo", + "Unsupported dictionary: {{reason}}": "Nepodprt slovar: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} slovar ni podprt in je onemogočen", + "{{count}} dictionary is unsupported and disabled_two": "{{count}} slovarja nista podprta in sta onemogočena", + "{{count}} dictionary is unsupported and disabled_few": "{{count}} slovarji niso podprti in so onemogočeni", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} slovarjev ni podprtih in so onemogočeni", + "No new dictionaries were imported": "Novi slovarji niso bili uvoženi" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 308e8bce..a7bd16e8 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "Skicka Reedy-feedback", "OpenRouter API Key": "OpenRouter-API-nyckel", "OpenRouter Base URL": "OpenRouter-bas-URL", - "OpenRouter Model": "OpenRouter-modell" + "OpenRouter Model": "OpenRouter-modell", + "Disable System Dictionary first to change this.": "Inaktivera Systemordbok först för att ändra detta.", + "App service is not available": "App-tjänsten är inte tillgänglig", + "Unsupported dictionary: {{reason}}": "Ordbok stöds inte: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} ordbok stöds inte och är inaktiverad", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} ordböcker stöds inte och är inaktiverade", + "No new dictionaries were imported": "Inga nya ordböcker importerades" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 35fe4485..362b048c 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "Reedy கருத்தை அனுப்பு", "OpenRouter API Key": "OpenRouter API விசை", "OpenRouter Base URL": "OpenRouter அடிப்படை URL", - "OpenRouter Model": "OpenRouter மாதிரி" + "OpenRouter Model": "OpenRouter மாதிரி", + "Disable System Dictionary first to change this.": "இதை மாற்ற, முதலில் கணினி அகராதியை முடக்கவும்.", + "App service is not available": "ஆப் சேவை கிடைக்கவில்லை", + "Unsupported dictionary: {{reason}}": "ஆதரிக்கப்படாத அகராதி: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} அகராதி ஆதரிக்கப்படவில்லை மற்றும் முடக்கப்பட்டுள்ளது", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} அகராதிகள் ஆதரிக்கப்படவில்லை மற்றும் முடக்கப்பட்டுள்ளன", + "No new dictionaries were imported": "புதிய அகராதிகள் எதுவும் இறக்குமதி செய்யப்படவில்லை" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 8544a226..7452282a 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1610,5 +1610,10 @@ "Send Reedy feedback": "ส่งความคิดเห็นเกี่ยวกับ Reedy", "OpenRouter API Key": "คีย์ API OpenRouter", "OpenRouter Base URL": "URL พื้นฐาน OpenRouter", - "OpenRouter Model": "โมเดล OpenRouter" + "OpenRouter Model": "โมเดล OpenRouter", + "Disable System Dictionary first to change this.": "ปิดใช้งานพจนานุกรมระบบก่อนเพื่อเปลี่ยนการตั้งค่านี้", + "App service is not available": "บริการแอปไม่พร้อมใช้งาน", + "Unsupported dictionary: {{reason}}": "พจนานุกรมที่ไม่รองรับ: {{reason}}", + "{{count}} dictionary is unsupported and disabled_other": "พจนานุกรม {{count}} รายการไม่รองรับและถูกปิดใช้งาน", + "No new dictionaries were imported": "ไม่มีพจนานุกรมใหม่ถูกนำเข้า" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 3507065d..78d83c4c 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "Reedy geri bildirimi gönder", "OpenRouter API Key": "OpenRouter API Anahtarı", "OpenRouter Base URL": "OpenRouter Temel URL", - "OpenRouter Model": "OpenRouter Modeli" + "OpenRouter Model": "OpenRouter Modeli", + "Disable System Dictionary first to change this.": "Bunu değiştirmek için önce Sistem Sözlüğü’nü devre dışı bırakın.", + "App service is not available": "Uygulama hizmeti kullanılamıyor", + "Unsupported dictionary: {{reason}}": "Desteklenmeyen sözlük: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} sözlük desteklenmiyor ve devre dışı bırakıldı", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} sözlük desteklenmiyor ve devre dışı bırakıldı", + "No new dictionaries were imported": "Yeni sözlük içe aktarılmadı" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 29634e77..3e293275 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1700,5 +1700,13 @@ "Send Reedy feedback": "Надіслати відгук про Reedy", "OpenRouter API Key": "Ключ API OpenRouter", "OpenRouter Base URL": "Базова URL-адреса OpenRouter", - "OpenRouter Model": "Модель OpenRouter" + "OpenRouter Model": "Модель OpenRouter", + "Disable System Dictionary first to change this.": "Спочатку вимкніть Системний словник, щоб змінити це.", + "App service is not available": "Служба застосунку недоступна", + "Unsupported dictionary: {{reason}}": "Непідтримуваний словник: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} словник не підтримується і вимкнено", + "{{count}} dictionary is unsupported and disabled_few": "{{count}} словники не підтримуються і вимкнено", + "{{count}} dictionary is unsupported and disabled_many": "{{count}} словників не підтримуються і вимкнено", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} словників не підтримуються і вимкнено", + "No new dictionaries were imported": "Нові словники не імпортовано" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index ab516aa8..79183061 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1640,5 +1640,11 @@ "Send Reedy feedback": "Reedy haqida fikr yuborish", "OpenRouter API Key": "OpenRouter API kaliti", "OpenRouter Base URL": "OpenRouter asosiy URL", - "OpenRouter Model": "OpenRouter modeli" + "OpenRouter Model": "OpenRouter modeli", + "Disable System Dictionary first to change this.": "Buni o‘zgartirish uchun avval Tizim lug‘atini o‘chiring.", + "App service is not available": "Ilova xizmati mavjud emas", + "Unsupported dictionary: {{reason}}": "Qo‘llab-quvvatlanmaydigan lug‘at: {{reason}}", + "{{count}} dictionary is unsupported and disabled_one": "{{count}} ta lug‘at qo‘llab-quvvatlanmaydi va o‘chirilgan", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} ta lug‘at qo‘llab-quvvatlanmaydi va o‘chirilgan", + "No new dictionaries were imported": "Yangi lug‘atlar import qilinmadi" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 683be3f0..6b3cb2ed 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1610,5 +1610,10 @@ "Send Reedy feedback": "Gửi phản hồi Reedy", "OpenRouter API Key": "Khóa API OpenRouter", "OpenRouter Base URL": "URL cơ sở OpenRouter", - "OpenRouter Model": "Mô hình OpenRouter" + "OpenRouter Model": "Mô hình OpenRouter", + "Disable System Dictionary first to change this.": "Tắt Từ điển hệ thống trước để thay đổi điều này.", + "App service is not available": "Dịch vụ ứng dụng không khả dụng", + "Unsupported dictionary: {{reason}}": "Từ điển không được hỗ trợ: {{reason}}", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} từ điển không được hỗ trợ và đã bị vô hiệu hóa", + "No new dictionaries were imported": "Không có từ điển mới nào được nhập" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 95e02fe5..094b41d5 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1610,5 +1610,10 @@ "Send Reedy feedback": "发送 Reedy 反馈", "OpenRouter API Key": "OpenRouter API 密钥", "OpenRouter Base URL": "OpenRouter 基础 URL", - "OpenRouter Model": "OpenRouter 模型" + "OpenRouter Model": "OpenRouter 模型", + "Disable System Dictionary first to change this.": "请先禁用系统词典再修改此项。", + "App service is not available": "应用服务不可用", + "Unsupported dictionary: {{reason}}": "不支持的词典:{{reason}}", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} 个词典不受支持,已停用", + "No new dictionaries were imported": "未导入任何新词典" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 7959aa1a..bc34da6a 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1610,5 +1610,10 @@ "Send Reedy feedback": "傳送 Reedy 意見回饋", "OpenRouter API Key": "OpenRouter API 金鑰", "OpenRouter Base URL": "OpenRouter 基礎 URL", - "OpenRouter Model": "OpenRouter 模型" + "OpenRouter Model": "OpenRouter 模型", + "Disable System Dictionary first to change this.": "請先停用系統字典以變更此項。", + "App service is not available": "應用程式服務無法使用", + "Unsupported dictionary: {{reason}}": "不支援的字典:{{reason}}", + "{{count}} dictionary is unsupported and disabled_other": "{{count}} 個字典不受支援,已停用", + "No new dictionaries were imported": "未匯入任何新字典" } diff --git a/apps/readest-app/src/__tests__/services/dictionaries/importStardictRawDict.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/importStardictRawDict.test.ts new file mode 100644 index 00000000..d5ba55ed --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/importStardictRawDict.test.ts @@ -0,0 +1,77 @@ +/** + * Importer accepts raw (uncompressed) `.dict` bodies, not just `.dict.dz`. + * The runtime body loader (`loadDictBody`) probes the gzip header and falls + * through to a passthrough buffer for raw files, so there's no reason to + * gate the import. + */ +import { describe, it, expect, vi } from 'vitest'; +import { gunzipSync } from 'fflate'; +import { importDictionaries } from '@/services/dictionaries/dictionaryService'; +import { + IFO_FIXTURE_NAME, + IDX_FIXTURE_NAME, + readIfoFile, + readIdxFile, + readDictFile, +} from './_stardictFixtures'; +import type { FileSystem } from '@/types/system'; + +function createMockFs(): FileSystem { + return { + resolvePath: vi + .fn() + .mockReturnValue({ baseDir: 0, basePrefix: async () => '', fp: 'x', base: 'Dictionaries' }), + getURL: vi.fn().mockReturnValue('url'), + getBlobURL: vi.fn().mockResolvedValue('blob:url'), + getImageURL: vi.fn().mockResolvedValue('image:url'), + openFile: vi.fn().mockResolvedValue(new File([], 'unused')), + copyFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(''), + writeFile: vi.fn().mockResolvedValue(undefined), + removeFile: vi.fn().mockResolvedValue(undefined), + readDir: vi.fn().mockResolvedValue([]), + createDir: vi.fn().mockResolvedValue(undefined), + removeDir: vi.fn().mockResolvedValue(undefined), + exists: vi.fn().mockResolvedValue(false), + stats: vi.fn().mockResolvedValue({ + isFile: true, + isDirectory: false, + size: 0, + mtime: null, + atime: null, + birthtime: null, + }), + getPrefix: vi.fn().mockResolvedValue('Readest/Dictionaries'), + }; +} + +async function readRawDictFile(): Promise { + // Inflate the fixture .dict.dz to get a raw .dict body. Same content as + // what the dictzip-compressed bundle would expand to, but stored without + // the gzip wrapper — exactly the case this test guards. + const dz = await readDictFile(); + const dzBytes = new Uint8Array(await dz.arrayBuffer()); + const raw = gunzipSync(dzBytes); + return new File([new Uint8Array(raw)], 'cmudict.dict'); +} + +describe('importDictionaries — StarDict raw .dict', () => { + it('imports a raw .dict bundle without flagging it unsupported', async () => { + const fs = createMockFs(); + const ifo = await readIfoFile(); + const idx = await readIdxFile(); + const dict = await readRawDictFile(); + + const result = await importDictionaries(fs, [{ file: ifo }, { file: idx }, { file: dict }]); + + expect(result.orphanFiles).toEqual([]); + expect(result.imported).toHaveLength(1); + const entry = result.imported[0]!; + expect(entry.kind).toBe('stardict'); + expect(entry.files.dict).toBe('cmudict.dict'); + expect(entry.files.ifo).toBe(IFO_FIXTURE_NAME); + expect(entry.files.idx).toBe(IDX_FIXTURE_NAME); + expect(entry.unsupported).toBeUndefined(); + expect(entry.unsupportedReason).toBeUndefined(); + }); +}); diff --git a/apps/readest-app/src/components/settings/CustomDictionaries.tsx b/apps/readest-app/src/components/settings/CustomDictionaries.tsx index 0408829c..7efa3de3 100644 --- a/apps/readest-app/src/components/settings/CustomDictionaries.tsx +++ b/apps/readest-app/src/components/settings/CustomDictionaries.tsx @@ -103,6 +103,10 @@ const builtinLabel = (id: string, _: (key: string) => string): string => { interface SortableRowProps { row: ProviderRow; enabled: boolean; + /** True when System Dictionary is on and this row is a non-system provider. + * The toggle reflects the persisted enabled flag (so the user sees what + * will come back when System is turned off) but is rendered read-only. */ + lockedBySystem: boolean; isDeleteMode: boolean; isEditMode: boolean; onToggle: (id: string, next: boolean) => void; @@ -115,6 +119,7 @@ interface SortableRowProps { const SortableRow: React.FC = ({ row, enabled, + lockedBySystem, isDeleteMode, isEditMode, onToggle, @@ -183,11 +188,15 @@ const SortableRow: React.FC = ({ onToggle(row.id, !enabled)} - disabled={row.disabled} + disabled={row.disabled || lockedBySystem} aria-label={enabled ? _('Disable') : _('Enable')} + title={lockedBySystem ? _('Disable System Dictionary first to change this.') : undefined} /> {/* Edit pencil — parity with the trailing delete X, but for the @@ -247,6 +256,7 @@ const CustomDictionaries: React.FC = ({ onBack }) => { removeWebSearch, saveCustomDictionaries, loadCustomDictionaries, + markAvailableByContentId, } = useCustomDictionaryStore(); useEffect(() => { @@ -453,18 +463,41 @@ const CustomDictionaries: React.FC = ({ onBack }) => { setImporting(true); try { const result = await selectFiles({ type: 'dictionaries', multiple: true }); - if (result.error || result.files.length === 0) return; + if (result.error) { + eventDispatcher.dispatch('toast', { + type: 'error', + message: _('Failed to import dictionary: {{message}}', { message: result.error }), + timeout: 4000, + }); + return; + } + // User cancelled the picker — staying silent is the right call here. + if (result.files.length === 0) return; const importResult = await appService?.importDictionaries(result.files, dictionaries); - if (!importResult) return; + if (!importResult) { + eventDispatcher.dispatch('toast', { + type: 'error', + message: _('Failed to import dictionary: {{message}}', { + message: _('App service is not available'), + }), + timeout: 4000, + }); + return; + } let added = 0; for (const dict of importResult.imported) { addDictionary(dict); + // The freshly imported bundle exists on disk now; clear any lingering + // `unavailable` flag on an in-memory entry with the same contentId + // (e.g. when a prior import lost its bundle dir for any reason). + if (dict.contentId) markAvailableByContentId(dict.contentId); if (appService) void queueDictionaryBinaryUpload(dict, appService); added += 1; } let replaced = 0; for (const { oldIds, newDict } of importResult.replacements) { replaceDictionaries(oldIds, newDict); + if (newDict.contentId) markAvailableByContentId(newDict.contentId); if (appService) void queueDictionaryBinaryUpload(newDict, appService); // Invalidate any cached provider instances for the replaced ids so // their next lookup picks up the new bundle's files. @@ -488,6 +521,26 @@ const CustomDictionaries: React.FC = ({ onBack }) => { timeout: 2500, }); } + // Bundles that landed in the library but are marked unsupported (e.g. + // record-block-encrypted MDX, raw .dict without DictZip). They show + // disabled in the list — surface the first reason so the user knows + // their "imported" toast doesn't mean it's usable. + const unsupportedDicts = [ + ...importResult.imported, + ...importResult.replacements.map((r) => r.newDict), + ].filter((d) => d.unsupported); + if (unsupportedDicts.length > 0) { + const firstReason = unsupportedDicts.find((d) => d.unsupportedReason)?.unsupportedReason; + eventDispatcher.dispatch('toast', { + type: 'warning', + message: firstReason + ? _('Unsupported dictionary: {{reason}}', { reason: firstReason }) + : _('{{count}} dictionary is unsupported and disabled', { + count: unsupportedDicts.length, + }), + timeout: 5000, + }); + } if (importResult.orphanFiles.length > 0) { eventDispatcher.dispatch('toast', { type: 'warning', @@ -497,6 +550,13 @@ const CustomDictionaries: React.FC = ({ onBack }) => { timeout: 4000, }); } + if (added === 0 && replaced === 0 && importResult.orphanFiles.length === 0) { + eventDispatcher.dispatch('toast', { + type: 'info', + message: _('No new dictionaries were imported'), + timeout: 2500, + }); + } } catch (err) { eventDispatcher.dispatch('toast', { type: 'error', @@ -652,6 +712,15 @@ const CustomDictionaries: React.FC = ({ onBack }) => { key={row.id} row={row} enabled={settings.providerEnabled[row.id] !== false} + // System-dictionary handoff is exclusive at lookup time, so + // while it's on the other providers' toggles only express a + // "what to restore when System is off" choice. Render them + // read-only so the user can't accidentally clear that + // restoration state. + lockedBySystem={ + settings.providerEnabled[BUILTIN_PROVIDER_IDS.systemDictionary] === true && + row.id !== BUILTIN_PROVIDER_IDS.systemDictionary + } isDeleteMode={isDeleteMode} isEditMode={isEditMode} onToggle={handleToggle} diff --git a/apps/readest-app/src/services/dictionaries/dictionaryService.ts b/apps/readest-app/src/services/dictionaries/dictionaryService.ts index 77db43b5..2c7d470e 100644 --- a/apps/readest-app/src/services/dictionaries/dictionaryService.ts +++ b/apps/readest-app/src/services/dictionaries/dictionaryService.ts @@ -25,9 +25,6 @@ import { shouldMintReincarnationForLiveReimport, } from './dictionaryDedup'; -/** 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; @@ -195,12 +192,6 @@ function parseIfo(text: string): Record { 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, @@ -265,25 +256,23 @@ async function importStarDictBundle( 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. + // Supported runtime surface: DictZip-compressed `.dict.dz` *or* raw `.dict` + // (the body is opened via `loadDictBody`, which probes the gzip header and + // falls through to a passthrough buffer for raw files). Restriction is on + // the entry shape: 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))) { + const seq = ifo['sametypesequence']; + if (!seq || seq.length !== 1) { 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.`; - } + 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.`; } // Stardict primary = .ifo (small text; partialMD5 is effectively full-hash). @@ -312,6 +301,54 @@ async function importStarDictBundle( }; } +/** + * Read just the XML header from an MDX/MDD file and pull out the fields we + * need at import time (Title, Encoding, Encrypted bitmap). Avoids triggering + * the multi-second full-init path inside `js-mdict`. + * + * MDX layout (V2): + * bytes [0..4) — big-endian uint32 N: byte length of the UTF-16 LE + * encoded XML header + * bytes [4..4+N) — UTF-16 LE XML string (ends with \x00\x00) + * bytes [4+N..8+N) — adler32 checksum (ignored here) + */ +async function readMdxHeader(file: File): Promise<{ + Title?: string; + Encoding?: string; + encrypt: number; +}> { + const sizeBuf = new Uint8Array(await file.slice(0, 4).arrayBuffer()); + if (sizeBuf.length < 4) { + throw new Error('MDX header truncated'); + } + const dv = new DataView(sizeBuf.buffer); + const headerByteSize = dv.getUint32(0, false); // big-endian + // Sanity-check: real MDX headers are typically a few hundred bytes to a few + // kilobytes. 1 MB is already absurd — likely a corrupt or non-MDX file. + if (headerByteSize === 0 || headerByteSize > 1024 * 1024) { + throw new Error(`MDX header size out of range: ${headerByteSize}`); + } + const xmlBuf = await file.slice(4, 4 + headerByteSize).arrayBuffer(); + const xml = new TextDecoder('utf-16le').decode(xmlBuf).replace(/+$/, ''); + const attrs: Record = {}; + for (const m of xml.matchAll(/(\w+)="((?:.|\r|\n)*?)"/g)) { + attrs[m[1]!] = m[2]!; + } + let encrypt = 0; + const encVal = attrs['Encrypted']; + if (!encVal || encVal === '' || encVal === 'No') encrypt = 0; + else if (encVal === 'Yes') encrypt = 1; + else { + const n = parseInt(encVal, 10); + encrypt = Number.isFinite(n) ? n : 0; + } + return { + Title: attrs['Title'], + Encoding: attrs['Encoding'], + encrypt, + }; +} + async function importMdictBundle(fs: FileSystem, group: MDictGroup): Promise { const bundleDir = await createBundleDir(fs); const mdxFile = await readSource(fs, group.mdx.source); @@ -326,28 +363,29 @@ async function importMdictBundle(fs: FileSystem, group: MDictGroup): Promise; - if (typeof header['Title'] === 'string' && (header['Title'] as string).trim()) { - name = (header['Title'] as string).trim(); + const header = await readMdxHeader(mdxFile); + if (header.Title && header.Title.trim()) { + name = header.Title.trim(); } - if (typeof header['Encoding'] === 'string') { - lang = (header['Encoding'] as string).toLowerCase(); + if (header.Encoding) { + lang = header.Encoding.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) { + // `encrypt` is a bitmap: 0x01 = record block encrypted (needs a + // user-supplied passcode/regcode — not supported), 0x02 = key info + // block encrypted (handled transparently by the runtime provider via + // ripemd128, no passcode needed). Only bit 0 is genuinely unsupported. + if ((header.encrypt & 1) !== 0) { unsupported = true; unsupportedReason = 'This MDX is registered to a specific user (record-block encryption); passcode-protected dictionaries are not supported.'; @@ -355,12 +393,8 @@ async function importMdictBundle(fs: FileSystem, group: MDictGroup): Promise\t" line. @@ -436,14 +468,8 @@ async function importDictBundle(fs: FileSystem, group: DictGroup): Promise { const { MDX, MDD } = (await import('js-mdict')) as { - MDX: { create(file: Blob): Promise }; - MDD: { create(file: Blob): Promise }; + MDX: { + create(file: Blob, options?: { lazy?: boolean }): Promise; + }; + MDD: { + create(file: Blob, options?: { lazy?: boolean }): Promise; + }; }; if (!dict.files.mdx) { @@ -331,7 +335,10 @@ export const createMdictProvider = ({ const mdxFile = await fs.openFile(`${dict.bundleDir}/${dict.files.mdx}`, 'Dictionaries'); let mdxInst: MDXInstance; try { - mdxInst = await MDX.create(mdxFile); + // Lazy mode: skip the upfront decompress-every-key-block + sort + // that costs ~80 s on a 250 MB MDX. Lookups decode only the + // relevant key block on demand (~tens of ms each). + mdxInst = await MDX.create(mdxFile, { lazy: true }); } catch (err) { const message = (err as Error).message ?? String(err); if (/encrypted file|user identification/i.test(message)) { @@ -361,7 +368,7 @@ export const createMdictProvider = ({ for (const name of mddNames) { try { const mddFile = await fs.openFile(`${dict.bundleDir}/${name}`, 'Dictionaries'); - mddInsts.push(await MDD.create(mddFile)); + mddInsts.push(await MDD.create(mddFile, { lazy: true })); } catch (err) { console.warn('Failed to open MDD resource bundle', name, err); } diff --git a/apps/readest-app/src/services/dictionaries/registry.ts b/apps/readest-app/src/services/dictionaries/registry.ts index c723cfa9..b99d717a 100644 --- a/apps/readest-app/src/services/dictionaries/registry.ts +++ b/apps/readest-app/src/services/dictionaries/registry.ts @@ -20,6 +20,7 @@ import type { WebSearchEntry, } from './types'; import { BUILTIN_PROVIDER_IDS } from './types'; +import { isSystemDictionarySupported } from './systemDictionary'; import { wiktionaryProvider } from './providers/wiktionaryProvider'; import { wikipediaProvider } from './providers/wikipediaProvider'; import { createStarDictProvider, type DictionaryFileOpener } from './providers/starDictProvider'; @@ -156,8 +157,16 @@ export const getEnabledProviders = ({ * of `getEnabledProviders` (which filters the sentinel out so no empty * tab appears) so callers don't need to look at order/list shape to * make the dispatch decision. + * + * Gated by platform support: `providerEnabled` is whole-field synced + * across devices, so a flag set on (say) macOS would otherwise reach a + * Windows device that has no handoff implementation, leaving the + * annotator with no way to look up a word. Short-circuiting here keeps + * the setting durable on the device where it makes sense while making + * it a no-op everywhere else. */ export const isSystemDictionaryEnabled = (settings: DictionarySettings): boolean => { + if (!isSystemDictionarySupported()) return false; return settings.providerEnabled[BUILTIN_PROVIDER_IDS.systemDictionary] === true; }; diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 2d6f30a6..d1802f9b 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -307,6 +307,22 @@ export const nativeFileSystem: FileSystem = { if (typeof content === 'string') { return writeTextFile(fp, content, baseDir ? { baseDir } : undefined); } else if (content instanceof File) { + // Fast path for NativeFile inputs (e.g. user-picked source on import): + // do a native filesystem copy at the Rust side rather than pumping + // ~1 MB chunks through the Tauri IPC bridge. On Android the stream + // path costs ~400 ms per IPC round-trip, which puts a 250 MB file + // at ~100 s; the native copy is bound by disk throughput instead. + try { + if (content instanceof NativeFile) { + const src = content.getNativeLocation(); + const opts: { fromPathBaseDir?: number; toPathBaseDir?: number } = {}; + if (src.baseDir != null) opts.fromPathBaseDir = src.baseDir; + if (baseDir) opts.toPathBaseDir = baseDir; + return await copyFile(src.path, fp, Object.keys(opts).length > 0 ? opts : undefined); + } + } catch (error) { + console.warn('Native copy failed, falling back to stream copy:', error); + } const writeOptions = { write: true, create: true, diff --git a/apps/readest-app/src/store/customDictionaryStore.ts b/apps/readest-app/src/store/customDictionaryStore.ts index 74d6f3c8..ad2d4b99 100644 --- a/apps/readest-app/src/store/customDictionaryStore.ts +++ b/apps/readest-app/src/store/customDictionaryStore.ts @@ -411,35 +411,16 @@ export const useCustomDictionaryStore = create((set, get) setEnabled: (id, enabled) => { set((state) => { - const SYSTEM_ID = BUILTIN_PROVIDER_IDS.systemDictionary; - // Exclusivity: enabling the system-dictionary entry disables all - // other providers, and enabling any non-system provider disables - // the system entry. The settings UI relies on this so the lookup - // path either ALWAYS opens the in-app popup or NEVER does — no - // mixed states. Disabling never cascades (toggling system off - // doesn't auto-enable the others; the user picks). - const isSystem = id === SYSTEM_ID; - const next: Record = { ...state.settings.providerEnabled }; - if (enabled) { - if (isSystem) { - // Turn off everything else. - for (const key of Object.keys(next)) { - if (key !== SYSTEM_ID) next[key] = false; - } - // Cover ids that may live in providerOrder without an - // explicit `false` entry (built-in defaults seed `true`). - for (const orderedId of state.settings.providerOrder) { - if (orderedId !== SYSTEM_ID) next[orderedId] = false; - } - next[SYSTEM_ID] = true; - } else { - // Enabling a non-system provider implicitly turns off system. - next[SYSTEM_ID] = false; - next[id] = true; - } - } else { - next[id] = false; - } + // System-dictionary exclusivity is enforced at LOOKUP time: + // `isSystemDictionaryEnabled` short-circuits to the OS handoff before + // any in-app provider runs. Persisting each provider's enabled state + // independently lets the user toggle System on/off without losing + // their preferred set of in-app providers — every flag is restored + // verbatim the moment System is turned back off. + const next: Record = { + ...state.settings.providerEnabled, + [id]: enabled, + }; return { settings: { ...state.settings, providerEnabled: next }, }; diff --git a/apps/readest-app/src/utils/file.ts b/apps/readest-app/src/utils/file.ts index 8d67af2c..9faa6471 100644 --- a/apps/readest-app/src/utils/file.ts +++ b/apps/readest-app/src/utils/file.ts @@ -110,6 +110,10 @@ export class NativeFile extends File implements ClosableFile { return this.#lastModified; } + getNativeLocation(): { path: string; baseDir: BaseDirectory | null } { + return { path: this.#fp, baseDir: this.#baseDir }; + } + async stat() { return this.#handle?.stat(); } diff --git a/apps/readest-app/src/utils/md5.ts b/apps/readest-app/src/utils/md5.ts index 47d2d9ec..2f389531 100644 --- a/apps/readest-app/src/utils/md5.ts +++ b/apps/readest-app/src/utils/md5.ts @@ -11,21 +11,21 @@ export function md5Fingerprint(value: string): string { export async function partialMD5(file: File): Promise { const step = 1024; const size = 1024; - const hasher = md5.create(); + const ranges: Array<[number, number]> = []; for (let i = -1; i <= 10; i++) { const start = Math.min(file.size, step << (2 * i)); const end = Math.min(start + size, file.size); - if (start >= file.size) break; - - const blobSlice = file.slice(start, end); - const arrayBuffer = await blobSlice.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - - hasher.update(uint8Array); + ranges.push([start, end]); + } + const chunks = await Promise.all( + ranges.map(([start, end]) => file.slice(start, end).arrayBuffer()), + ); + const hasher = md5.create(); + for (const buf of chunks) { + hasher.update(new Uint8Array(buf)); } - return hasher.hex(); } diff --git a/packages/js-mdict b/packages/js-mdict index 52ac3877..d01bf62a 160000 --- a/packages/js-mdict +++ b/packages/js-mdict @@ -1 +1 @@ -Subproject commit 52ac38779da802d718da69155545898303eb2c8e +Subproject commit d01bf62af872b1fbeacb2f18446460960e7400de