feat(dict): faster MDict/StarDict import + lazy lookup; raw .dict; UX (#4334)

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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-28 13:48:16 +08:00
committed by GitHub
parent 381eed21cc
commit 93abca8960
43 changed files with 533 additions and 138 deletions
@@ -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": "لم يتم استيراد أي قواميس جديدة"
}
@@ -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": "কোনো নতুন অভিধান আমদানি করা হয়নি"
}
@@ -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": "ཚིག་མཛོད་གསར་པ་ནང་འདྲེན་མ་བྱུང་།"
}
@@ -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"
}
@@ -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": "Δεν εισήχθησαν νέα λεξικά"
}
@@ -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"
}
@@ -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": "هیچ فرهنگ لغت جدیدی وارد نشد"
}
@@ -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 dabord le dictionnaire système pour modifier ceci.",
"App service is not available": "Le service de lapplication nest pas disponible",
"Unsupported dictionary: {{reason}}": "Dictionnaire non pris en charge : {{reason}}",
"{{count}} dictionary is unsupported and disabled_one": "{{count}} dictionnaire nest 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 na été importé"
}
@@ -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": "לא יובאו מילונים חדשים"
}
@@ -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": "कोई नया शब्दकोश आयात नहीं किया गया"
}
@@ -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"
}
@@ -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"
}
@@ -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 dellapp 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"
}
@@ -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": "新しい辞書は読み込まれませんでした"
}
@@ -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": "가져온 새 사전이 없습니다"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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": "Новые словари не были импортированы"
}
@@ -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": "නව ශබ්දකෝෂ ආයාත කර නැත"
}
@@ -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"
}
@@ -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"
}
@@ -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": "புதிய அகராதிகள் எதுவும் இறக்குமதி செய்யப்படவில்லை"
}
@@ -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": "ไม่มีพจนานุกรมใหม่ถูกนำเข้า"
}
@@ -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ı"
}
@@ -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": "Нові словники не імпортовано"
}
@@ -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 ozgartirish uchun avval Tizim lugatini ochiring.",
"App service is not available": "Ilova xizmati mavjud emas",
"Unsupported dictionary: {{reason}}": "Qollab-quvvatlanmaydigan lugat: {{reason}}",
"{{count}} dictionary is unsupported and disabled_one": "{{count}} ta lugat qollab-quvvatlanmaydi va ochirilgan",
"{{count}} dictionary is unsupported and disabled_other": "{{count}} ta lugat qollab-quvvatlanmaydi va ochirilgan",
"No new dictionaries were imported": "Yangi lugatlar import qilinmadi"
}
@@ -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"
}
@@ -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": "未导入任何新词典"
}
@@ -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": "未匯入任何新字典"
}
@@ -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<File> {
// 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();
});
});
@@ -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<SortableRowProps> = ({
row,
enabled,
lockedBySystem,
isDeleteMode,
isEditMode,
onToggle,
@@ -183,11 +188,15 @@ const SortableRow: React.FC<SortableRowProps> = ({
<input
type='checkbox'
className='toggle toggle-sm shrink-0'
className={clsx(
'toggle toggle-sm shrink-0',
lockedBySystem && 'cursor-not-allowed opacity-60',
)}
checked={enabled}
onChange={() => 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<CustomDictionariesProps> = ({ onBack }) => {
removeWebSearch,
saveCustomDictionaries,
loadCustomDictionaries,
markAvailableByContentId,
} = useCustomDictionaryStore();
useEffect(() => {
@@ -453,18 +463,41 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ 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<CustomDictionariesProps> = ({ 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<CustomDictionariesProps> = ({ 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<CustomDictionariesProps> = ({ 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}
@@ -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<string, string> {
return out;
}
/** Detect the GZIP magic at the start of a Blob. */
async function isGzip(file: File): Promise<boolean> {
const head = new Uint8Array(await file.slice(0, 3).arrayBuffer());
return head[0] === GZIP_MAGIC[0] && head[1] === GZIP_MAGIC[1] && head[2] === GZIP_MAGIC[2];
}
async function writeBundleFile(
fs: FileSystem,
bundleDir: string,
@@ -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<string, string> = {};
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<ImportedDictionary> {
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<Imp
await writeBundleFile(fs, bundleDir, group.css[i]!.name, cssFiles[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.
// Read only the small XML header at the start of the file. We need
// Title / Encoding / Encrypted — all live in the header. The full
// `MDX.create()` factory would additionally decompress every key block
// and sort millions of keys (~17s on a 250 MB MDX), which we don't need
// here. The runtime provider still uses MDX.create() lazily on first
// lookup, so init cost is paid once at usage time, not at import time.
let name = group.stem;
let lang: string | undefined;
let unsupported = false;
let unsupportedReason: string | undefined;
try {
const { MDX } = await import('js-mdict');
const mdx = await MDX.create(mdxFile);
const header = mdx.header as Record<string, unknown>;
if (typeof header['Title'] === 'string' && (header['Title'] as string).trim()) {
name = (header['Title'] as string).trim();
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<Imp
} 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}`;
}
unsupportedReason = `Failed to parse MDX header: ${message}`;
console.warn(`MDX import: failed to parse "${group.mdx.name}": ${message}`, err);
}
// MDict primary = .mdx (the body file).
@@ -400,8 +434,6 @@ async function importDictBundle(fs: FileSystem, group: DictGroup): Promise<Impor
// index lists it; the body lives in the dict. We do this best-effort: any
// failure falls back to the stem.
let name = group.stem;
let unsupported = false;
let unsupportedReason: string | undefined;
try {
const indexText = await indexFile.text();
// Find the "00databaseshort\t<offset>\t<size>" line.
@@ -436,14 +468,8 @@ async function importDictBundle(fs: FileSystem, group: DictGroup): Promise<Impor
} catch {
// Best-effort label; the bundle is still importable.
}
if (!(await isGzip(dictFile))) {
// Plain `.dict` is technically supported by the reader, but we keep
// v1 scope identical to StarDict for consistency.
unsupported = true;
unsupportedReason = 'Raw .dict files are not supported in v1; please use .dict.dz format.';
}
// DICT primary = .dict (or .dict.dz) — the gzipped body file.
// DICT primary = .dict (or .dict.dz). The runtime body loader probes the
// gzip header and falls through to a passthrough buffer for raw files.
const dictFilenames = [group.dict.name, group.index.name];
const contentId = await computeDictionaryContentId(dictFile, dictFilenames);
@@ -458,8 +484,6 @@ async function importDictBundle(fs: FileSystem, group: DictGroup): Promise<Impor
dict: group.dict.name,
},
addedAt: Date.now(),
unsupported: unsupported || undefined,
unsupportedReason,
};
}
@@ -321,8 +321,12 @@ export const createMdictProvider = ({
if (!initPromise) {
initPromise = (async () => {
const { MDX, MDD } = (await import('js-mdict')) as {
MDX: { create(file: Blob): Promise<MDXInstance> };
MDD: { create(file: Blob): Promise<MDDInstance> };
MDX: {
create(file: Blob, options?: { lazy?: boolean }): Promise<MDXInstance>;
};
MDD: {
create(file: Blob, options?: { lazy?: boolean }): Promise<MDDInstance>;
};
};
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);
}
@@ -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;
};
@@ -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,
@@ -411,35 +411,16 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((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<string, boolean> = { ...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<string, boolean> = {
...state.settings.providerEnabled,
[id]: enabled,
};
return {
settings: { ...state.settings, providerEnabled: next },
};
+4
View File
@@ -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();
}
+9 -9
View File
@@ -11,21 +11,21 @@ export function md5Fingerprint(value: string): string {
export async function partialMD5(file: File): Promise<string> {
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();
}