feat(dictionaries): add DICT/Slob formats and Web Search providers (#4048)

Extends the dictionary system beyond StarDict/MDict with two more open
formats and a pluggable Web Search tier so users can fall back to online
sources when their offline bundles miss a word.

Formats:
- DICT (dictd, RFC 2229): .index + .dict.dz bundles. Shared DictZip
  parsing with StarDict via new dictZip.ts helper.
- Slob (Aard 2): self-contained .slob containers, zlib-compressed
  utf-8 entries; non-zlib/non-utf-8 bundles flagged unsupported at
  import.

Web Search:
- Built-in templates for Google, Urban Dictionary, Merriam-Webster
  (seeded into providerOrder, disabled by default).
- Custom URL templates via %WORD% placeholder, URL-encoded at
  substitution; entries persist in settings.webSearches.
- V1 renders an "Open in {{name}}" external link (iframe embedding is
  blocked by every major target site's X-Frame-Options).

UI:
- CustomDictionaries panel: flat outline-primary buttons for Import /
  Add Web Search, end-aligned type badges for a uniform column,
  hover states, compact tips block.
- Dictionary popup: bottom-right Manage icon (tooltip-only) deep-links
  into Settings → Language → Dictionaries; rounded-corner clipping fix
  on the tab strip.

File picker accepts .index and .slob; importer recognizes DICT and
Slob bundles and reads bundle metadata for friendly names.

Tests cover DICT/Slob readers and providers with real freedict-eng-nld
fixtures, web search substitution + provider rendering, and the new
store CRUD for web searches.

Closes #4038
This commit is contained in:
Huang Xin
2026-05-04 02:07:27 +08:00
committed by GitHub
parent 8e7b2192d5
commit 7bb1133706
58 changed files with 11059 additions and 391 deletions
@@ -1306,7 +1306,6 @@
"Importing…": "جاري الاستيراد…",
"Import Dictionary": "استيراد قاموس",
"No dictionaries available.": "لا تتوفر قواميس.",
"Drag the handle on the left to reorder.": "اسحب المقبض الموجود على اليسار لإعادة الترتيب.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "تحتاج حزم StarDict إلى ملفات .ifo و‎.idx و‎.dict.dz (الـ‎.syn اختياري).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "حزم MDict تستخدم ملفات ‎.mdx؛ ملفات ‎.mdd المرافقة اختيارية.",
"Select all the bundle files together when importing.": "حدد جميع ملفات الحزمة معًا عند الاستيراد.",
@@ -1409,5 +1408,18 @@
"Includes your reading progress": "يتضمن تقدّم قراءتك",
"Synced {{time}}": "تمت المزامنة {{time}}",
"Sync Info": "معلومات المزامنة",
"Last Synced": "آخر مزامنة"
"Last Synced": "آخر مزامنة",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "تحتاج حزم DICT إلى ملف ‎.index وملف .dict.dz.",
"Slob bundles need a .slob file.": "تحتاج حزم Slob إلى ملف .slob.",
"URL template must start with http(s):// and contain %WORD%.": "يجب أن يبدأ قالب URL بـ http(s):// وأن يحتوي على %WORD%.",
"Web": "ويب",
"Add Web Search": "إضافة بحث ويب",
"Edit Web Search": "تعديل بحث الويب",
"e.g. Google": "مثال: Google",
"URL Template": "قالب URL",
"Use %WORD% where the looked-up word should appear.": "استخدم %WORD% حيث يجب أن تظهر الكلمة المبحوث عنها.",
"Open the search result in your browser:": "افتح نتيجة البحث في متصفحك:",
"Open in {{name}}": "افتح في {{name}}"
}
@@ -1234,7 +1234,6 @@
"Importing…": "আমদানি হচ্ছে…",
"Import Dictionary": "অভিধান আমদানি করুন",
"No dictionaries available.": "কোনো অভিধান উপলব্ধ নেই।",
"Drag the handle on the left to reorder.": "পুনঃক্রম করতে বামদিকের হ্যান্ডেলটি টানুন।",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict বান্ডলে .ifo, .idx এবং .dict.dz ফাইল প্রয়োজন (.syn ঐচ্ছিক)।",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict বান্ডলে .mdx ফাইল ব্যবহৃত হয়; সঙ্গী .mdd ফাইল ঐচ্ছিক।",
"Select all the bundle files together when importing.": "আমদানির সময় বান্ডলের সব ফাইল একসঙ্গে নির্বাচন করুন।",
@@ -1317,5 +1316,18 @@
"Includes your reading progress": "আপনার পড়ার অগ্রগতি অন্তর্ভুক্ত",
"Synced {{time}}": "{{time}} সিঙ্ক হয়েছে",
"Sync Info": "সিঙ্ক তথ্য",
"Last Synced": "শেষ সিঙ্ক"
"Last Synced": "শেষ সিঙ্ক",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT বান্ডেলের জন্য একটি .index ফাইল এবং একটি .dict.dz ফাইল প্রয়োজন।",
"Slob bundles need a .slob file.": "Slob বান্ডেলের জন্য একটি .slob ফাইল প্রয়োজন।",
"URL template must start with http(s):// and contain %WORD%.": "URL টেমপ্লেটটি অবশ্যই http(s):// দিয়ে শুরু হতে হবে এবং %WORD% থাকতে হবে।",
"Web": "ওয়েব",
"Add Web Search": "ওয়েব অনুসন্ধান যোগ করুন",
"Edit Web Search": "ওয়েব অনুসন্ধান সম্পাদনা করুন",
"e.g. Google": "যেমন Google",
"URL Template": "URL টেমপ্লেট",
"Use %WORD% where the looked-up word should appear.": "যেখানে অনুসন্ধান করা শব্দ উপস্থিত হওয়া উচিত সেখানে %WORD% ব্যবহার করুন।",
"Open the search result in your browser:": "আপনার ব্রাউজারে অনুসন্ধান ফলাফল খুলুন:",
"Open in {{name}}": "{{name}}-এ খুলুন"
}
@@ -1216,7 +1216,6 @@
"Importing…": "ནང་འདྲེན་བྱེད་བཞིན་པ…",
"Import Dictionary": "ཚིག་མཛོད་ནང་འདྲེན།",
"No dictionaries available.": "ཚིག་མཛོད་གང་ཡང་མི་འདུག།",
"Drag the handle on the left to reorder.": "གོ་རིམ་སྒྱུར་བར་གཡོན་ཕྱོགས་ཀྱི་འཛིན་ཕུང་འཐེན།",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict ཡོ་བྱད་ལ་ .ifo དང་ .idx, .dict.dz ཡིག་ཆ་ཁག་དགོས་པ་ཡིན། (.syn ནི་འདེམས་འཁྱོངས་ཡིན།)",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict ཡོ་བྱད་ཀྱིས་ .mdx ཡིག་ཆ་སྤྱོད། མཉམ་འགྲོགས་ .mdd ཡིག་ཆ་ནི་འདེམས་འཁྱོངས་ཡིན།",
"Select all the bundle files together when importing.": "ནང་འདྲེན་སྐབས་ཡོ་བྱད་ཡིག་ཆ་ཡོངས་མཉམ་དུ་འདེམས་རོགས།",
@@ -1294,5 +1293,18 @@
"Includes your reading progress": "ཁྱེད་ཀྱི་ཀློག་འདོན་གྱི་ཡར་ཐོན་འདུས།",
"Synced {{time}}": "{{time}}མཉམ་སྒྲིག་བྱས་པ",
"Sync Info": "མཉམ་སྒྲིག་གྱི་ཆ་འཕྲིན",
"Last Synced": "མཐའ་མའི་མཉམ་སྒྲིག"
"Last Synced": "མཐའ་མའི་མཉམ་སྒྲིག",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT ཕུང་པོར་ .index ཡིག་ཆ་དང་ .dict.dz ཡིག་ཆ་གཉིས་ཀ་དགོས།",
"Slob bundles need a .slob file.": "Slob ཕུང་པོར་ .slob ཡིག་ཆ་ཞིག་དགོས།",
"URL template must start with http(s):// and contain %WORD%.": "URL ཕྱི་མོ་ http(s):// གིས་འགོ་འཛུགས་དགོས་ལ་ %WORD% ཡོད་དགོས།",
"Web": "དྲ་རྒྱ",
"Add Web Search": "དྲ་རྒྱའི་འཚོལ་བཤེར་སྣོན།",
"Edit Web Search": "དྲ་རྒྱའི་འཚོལ་བཤེར་རྩོམ་སྒྲིག",
"e.g. Google": "དཔེར་ན། Google",
"URL Template": "URL ཕྱི་མོ",
"Use %WORD% where the looked-up word should appear.": "འཚོལ་བཤེར་བྱས་པའི་ཚིག་འཆར་ས་གནས་སུ་ %WORD% བཀོལ་སྤྱོད་གནང་རོགས།",
"Open the search result in your browser:": "ཁྱེད་ཀྱི་བརྡ་འཚོལ་མཁོ་ཆས་ལ་འཚོལ་བཤེར་གྲུབ་འབྲས་ཁ་ཕྱེ:",
"Open in {{name}}": "{{name}} ནང་ཁ་ཕྱེ།"
}
@@ -1234,7 +1234,6 @@
"Importing…": "Wird importiert …",
"Import Dictionary": "Wörterbuch importieren",
"No dictionaries available.": "Keine Wörterbücher verfügbar.",
"Drag the handle on the left to reorder.": "Ziehe am Griff links, um die Reihenfolge zu ändern.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict-Bundles benötigen .ifo-, .idx- und .dict.dz-Dateien (.syn optional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict-Bundles verwenden .mdx-Dateien; begleitende .mdd-Dateien sind optional.",
"Select all the bundle files together when importing.": "Wähle beim Import alle Bundle-Dateien zusammen aus.",
@@ -1317,5 +1316,18 @@
"Includes your reading progress": "Enthält deinen Lesefortschritt",
"Synced {{time}}": "Synchronisiert {{time}}",
"Sync Info": "Synchronisierungs-Info",
"Last Synced": "Zuletzt synchronisiert"
"Last Synced": "Zuletzt synchronisiert",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT-Bundles benötigen eine .index-Datei und eine .dict.dz-Datei.",
"Slob bundles need a .slob file.": "Slob-Bundles benötigen eine .slob-Datei.",
"URL template must start with http(s):// and contain %WORD%.": "URL-Vorlage muss mit http(s):// beginnen und %WORD% enthalten.",
"Web": "Web",
"Add Web Search": "Websuche hinzufügen",
"Edit Web Search": "Websuche bearbeiten",
"e.g. Google": "z. B. Google",
"URL Template": "URL-Vorlage",
"Use %WORD% where the looked-up word should appear.": "Verwenden Sie %WORD%, wo das gesuchte Wort erscheinen soll.",
"Open the search result in your browser:": "Öffne das Suchergebnis in deinem Browser:",
"Open in {{name}}": "In {{name}} öffnen"
}
@@ -1234,7 +1234,6 @@
"Importing…": "Εισαγωγή…",
"Import Dictionary": "Εισαγωγή λεξικού",
"No dictionaries available.": "Δεν υπάρχουν διαθέσιμα λεξικά.",
"Drag the handle on the left to reorder.": "Σύρετε τη λαβή αριστερά για επαναταξινόμηση.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Τα πακέτα StarDict χρειάζονται αρχεία .ifo, .idx και .dict.dz (το .syn είναι προαιρετικό).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Τα πακέτα MDict χρησιμοποιούν αρχεία .mdx· τα συνοδευτικά .mdd είναι προαιρετικά.",
"Select all the bundle files together when importing.": "Επιλέξτε όλα τα αρχεία του πακέτου μαζί κατά την εισαγωγή.",
@@ -1317,5 +1316,18 @@
"Includes your reading progress": "Περιλαμβάνει την πρόοδο ανάγνωσής σας",
"Synced {{time}}": "Συγχρονίστηκε {{time}}",
"Sync Info": "Πληροφορίες συγχρονισμού",
"Last Synced": "Τελευταίος συγχρονισμός"
"Last Synced": "Τελευταίος συγχρονισμός",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Τα πακέτα DICT χρειάζονται ένα αρχείο .index και ένα αρχείο .dict.dz.",
"Slob bundles need a .slob file.": "Τα πακέτα Slob χρειάζονται ένα αρχείο .slob.",
"URL template must start with http(s):// and contain %WORD%.": "Το πρότυπο URL πρέπει να ξεκινά με http(s):// και να περιέχει %WORD%.",
"Web": "Διαδίκτυο",
"Add Web Search": "Προσθήκη αναζήτησης ιστού",
"Edit Web Search": "Επεξεργασία αναζήτησης ιστού",
"e.g. Google": "π.χ. Google",
"URL Template": "Πρότυπο URL",
"Use %WORD% where the looked-up word should appear.": "Χρησιμοποιήστε %WORD% όπου πρέπει να εμφανίζεται η λέξη αναζήτησης.",
"Open the search result in your browser:": "Ανοίξτε το αποτέλεσμα αναζήτησης στο πρόγραμμα περιήγησής σας:",
"Open in {{name}}": "Άνοιγμα σε {{name}}"
}
@@ -1252,7 +1252,6 @@
"Importing…": "Importando…",
"Import Dictionary": "Importar diccionario",
"No dictionaries available.": "No hay diccionarios disponibles.",
"Drag the handle on the left to reorder.": "Arrastra el asa de la izquierda para reordenar.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Los paquetes StarDict requieren archivos .ifo, .idx y .dict.dz (.syn opcional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Los paquetes MDict usan archivos .mdx; los .mdd asociados son opcionales.",
"Select all the bundle files together when importing.": "Selecciona todos los archivos del paquete juntos al importar.",
@@ -1340,5 +1339,18 @@
"Includes your reading progress": "Incluye tu progreso de lectura",
"Synced {{time}}": "Sincronizado {{time}}",
"Sync Info": "Información de sincronización",
"Last Synced": "Última sincronización"
"Last Synced": "Última sincronización",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Los paquetes DICT necesitan un archivo .index y un archivo .dict.dz.",
"Slob bundles need a .slob file.": "Los paquetes Slob necesitan un archivo .slob.",
"URL template must start with http(s):// and contain %WORD%.": "La plantilla de URL debe empezar con http(s):// y contener %WORD%.",
"Web": "Web",
"Add Web Search": "Añadir búsqueda web",
"Edit Web Search": "Editar búsqueda web",
"e.g. Google": "p. ej. Google",
"URL Template": "Plantilla de URL",
"Use %WORD% where the looked-up word should appear.": "Use %WORD% donde debe aparecer la palabra buscada.",
"Open the search result in your browser:": "Abre el resultado de búsqueda en tu navegador:",
"Open in {{name}}": "Abrir en {{name}}"
}
@@ -1234,7 +1234,6 @@
"Importing…": "در حال وارد کردن…",
"Import Dictionary": "وارد کردن فرهنگ‌لغت",
"No dictionaries available.": "هیچ فرهنگ‌لغتی در دسترس نیست.",
"Drag the handle on the left to reorder.": "برای تغییر ترتیب، دستگیرهٔ سمت چپ را بکشید.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "بسته‌های StarDict به فایل‌های .ifo، .idx و .dict.dz نیاز دارند (.syn اختیاری).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "بسته‌های MDict از فایل‌های .mdx استفاده می‌کنند؛ فایل‌های همراه .mdd اختیاری‌اند.",
"Select all the bundle files together when importing.": "هنگام وارد کردن، همه فایل‌های بسته را با هم انتخاب کنید.",
@@ -1317,5 +1316,18 @@
"Includes your reading progress": "شامل پیشرفت مطالعهٔ شماست",
"Synced {{time}}": "{{time}} همگام‌سازی شد",
"Sync Info": "اطلاعات همگام‌سازی",
"Last Synced": "آخرین همگام‌سازی"
"Last Synced": "آخرین همگام‌سازی",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "بسته‌های DICT به یک فایل ‎.index‎ و یک فایل ‎.dict.dz‎ نیاز دارند.",
"Slob bundles need a .slob file.": "بسته‌های Slob به یک فایل ‎.slob‎ نیاز دارند.",
"URL template must start with http(s):// and contain %WORD%.": "الگوی URL باید با http(s):// شروع شود و حاوی %WORD% باشد.",
"Web": "وب",
"Add Web Search": "افزودن جست‌وجوی وب",
"Edit Web Search": "ویرایش جست‌وجوی وب",
"e.g. Google": "مثلاً Google",
"URL Template": "الگوی URL",
"Use %WORD% where the looked-up word should appear.": "از %WORD% در جایی که کلمه‌ی مورد جست‌وجو باید ظاهر شود، استفاده کنید.",
"Open the search result in your browser:": "نتیجه‌ی جست‌وجو را در مرورگر باز کنید:",
"Open in {{name}}": "بازکردن در {{name}}"
}
@@ -1252,7 +1252,6 @@
"Importing…": "Importation…",
"Import Dictionary": "Importer un dictionnaire",
"No dictionaries available.": "Aucun dictionnaire disponible.",
"Drag the handle on the left to reorder.": "Glissez la poignée à gauche pour réorganiser.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Les paquets StarDict nécessitent des fichiers .ifo, .idx et .dict.dz (.syn optionnel).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Les paquets MDict utilisent des fichiers .mdx ; les .mdd associés sont optionnels.",
"Select all the bundle files together when importing.": "Sélectionnez tous les fichiers du paquet ensemble lors de limport.",
@@ -1340,5 +1339,18 @@
"Includes your reading progress": "Inclut votre progression de lecture",
"Synced {{time}}": "Synchronisé {{time}}",
"Sync Info": "Infos de synchronisation",
"Last Synced": "Dernière synchronisation"
"Last Synced": "Dernière synchronisation",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Les paquets DICT nécessitent un fichier .index et un fichier .dict.dz.",
"Slob bundles need a .slob file.": "Les paquets Slob nécessitent un fichier .slob.",
"URL template must start with http(s):// and contain %WORD%.": "Le modèle d'URL doit commencer par http(s):// et contenir %WORD%.",
"Web": "Web",
"Add Web Search": "Ajouter une recherche web",
"Edit Web Search": "Modifier la recherche web",
"e.g. Google": "ex. Google",
"URL Template": "Modèle d'URL",
"Use %WORD% where the looked-up word should appear.": "Utilisez %WORD% à l'endroit où le mot recherché doit apparaître.",
"Open the search result in your browser:": "Ouvrir le résultat de la recherche dans votre navigateur :",
"Open in {{name}}": "Ouvrir dans {{name}}"
}
@@ -1252,7 +1252,6 @@
"Importing…": "מייבא…",
"Import Dictionary": "ייבא מילון",
"No dictionaries available.": "אין מילונים זמינים.",
"Drag the handle on the left to reorder.": "גררו את הידית משמאל כדי לסדר מחדש.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "חבילות StarDict דורשות קבצי .ifo, .idx ו-.dict.dz (.syn אופציונלי).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "חבילות MDict משתמשות בקבצי .mdx; קבצי .mdd נלווים הם אופציונליים.",
"Select all the bundle files together when importing.": "בחרו את כל קבצי החבילה יחד בעת הייבוא.",
@@ -1340,5 +1339,18 @@
"Includes your reading progress": "כולל את התקדמות הקריאה שלך",
"Synced {{time}}": "סונכרן {{time}}",
"Sync Info": "פרטי סנכרון",
"Last Synced": "סונכרן לאחרונה"
"Last Synced": "סונכרן לאחרונה",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "חבילות DICT דורשות קובץ ‎.index וקובץ .dict.dz.",
"Slob bundles need a .slob file.": "חבילות Slob דורשות קובץ .slob.",
"URL template must start with http(s):// and contain %WORD%.": "תבנית כתובת ה-URL חייבת להתחיל ב-http(s):// ולהכיל %WORD%.",
"Web": "אינטרנט",
"Add Web Search": "הוסף חיפוש באינטרנט",
"Edit Web Search": "ערוך חיפוש באינטרנט",
"e.g. Google": "לדוגמה: Google",
"URL Template": "תבנית URL",
"Use %WORD% where the looked-up word should appear.": "השתמש ב-%WORD% היכן שהמילה המבוקשת צריכה להופיע.",
"Open the search result in your browser:": "פתח את תוצאת החיפוש בדפדפן שלך:",
"Open in {{name}}": "פתח ב-{{name}}"
}
@@ -1234,7 +1234,6 @@
"Importing…": "आयात हो रहा है…",
"Import Dictionary": "शब्दकोश आयात करें",
"No dictionaries available.": "कोई शब्दकोश उपलब्ध नहीं है।",
"Drag the handle on the left to reorder.": "पुनः क्रमित करने के लिए बायीं ओर के हैंडल को खींचें।",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict बंडल को .ifo, .idx और .dict.dz फ़ाइलें चाहिए (.syn वैकल्पिक)।",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict बंडल .mdx फ़ाइलों का उपयोग करते हैं; साथी .mdd फ़ाइलें वैकल्पिक हैं।",
"Select all the bundle files together when importing.": "आयात करते समय बंडल की सभी फ़ाइलें एक साथ चुनें।",
@@ -1317,5 +1316,18 @@
"Includes your reading progress": "आपकी पढ़ने की प्रगति शामिल है",
"Synced {{time}}": "{{time}} सिंक हुआ",
"Sync Info": "सिंक जानकारी",
"Last Synced": "अंतिम सिंक"
"Last Synced": "अंतिम सिंक",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT बंडल को एक .index फ़ाइल और एक .dict.dz फ़ाइल की आवश्यकता होती है।",
"Slob bundles need a .slob file.": "Slob बंडल को एक .slob फ़ाइल की आवश्यकता होती है।",
"URL template must start with http(s):// and contain %WORD%.": "URL टेम्पलेट http(s):// से शुरू होना चाहिए और उसमें %WORD% होना चाहिए।",
"Web": "वेब",
"Add Web Search": "वेब खोज जोड़ें",
"Edit Web Search": "वेब खोज संपादित करें",
"e.g. Google": "उदा. Google",
"URL Template": "URL टेम्पलेट",
"Use %WORD% where the looked-up word should appear.": "%WORD% का उपयोग वहां करें जहां खोजा गया शब्द दिखाई देना चाहिए।",
"Open the search result in your browser:": "अपने ब्राउज़र में खोज परिणाम खोलें:",
"Open in {{name}}": "{{name}} में खोलें"
}
@@ -1234,7 +1234,6 @@
"Importing…": "Importálás…",
"Import Dictionary": "Szótár importálása",
"No dictionaries available.": "Nincsenek elérhető szótárak.",
"Drag the handle on the left to reorder.": "A bal oldali fogantyúval rendezheted át.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "A StarDict csomagokhoz .ifo, .idx és .dict.dz fájlok szükségesek (.syn opcionális).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Az MDict csomagok .mdx fájlokat használnak; a kísérő .mdd fájlok opcionálisak.",
"Select all the bundle files together when importing.": "Importáláskor jelöld ki egyszerre a csomag összes fájlját.",
@@ -1317,5 +1316,18 @@
"Includes your reading progress": "Tartalmazza az olvasási folyamatot",
"Synced {{time}}": "Szinkronizálva {{time}}",
"Sync Info": "Szinkronizálási információk",
"Last Synced": "Utolsó szinkronizálás"
"Last Synced": "Utolsó szinkronizálás",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "A DICT csomagokhoz egy .index fájl és egy .dict.dz fájl szükséges.",
"Slob bundles need a .slob file.": "A Slob csomagokhoz egy .slob fájl szükséges.",
"URL template must start with http(s):// and contain %WORD%.": "Az URL-sablonnak http(s):// kezdetűnek kell lennie, és tartalmaznia kell a %WORD% részt.",
"Web": "Web",
"Add Web Search": "Webes keresés hozzáadása",
"Edit Web Search": "Webes keresés szerkesztése",
"e.g. Google": "pl. Google",
"URL Template": "URL-sablon",
"Use %WORD% where the looked-up word should appear.": "Használja a %WORD%-t ott, ahol a keresett szónak meg kell jelennie.",
"Open the search result in your browser:": "Nyissa meg a találatot a böngészőben:",
"Open in {{name}}": "Megnyitás itt: {{name}}"
}
@@ -1216,7 +1216,6 @@
"Importing…": "Mengimpor…",
"Import Dictionary": "Impor Kamus",
"No dictionaries available.": "Tidak ada kamus yang tersedia.",
"Drag the handle on the left to reorder.": "Seret pegangan di sebelah kiri untuk mengurutkan ulang.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Paket StarDict memerlukan file .ifo, .idx, dan .dict.dz (.syn opsional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Paket MDict menggunakan file .mdx; file .mdd pendamping bersifat opsional.",
"Select all the bundle files together when importing.": "Pilih semua file paket sekaligus saat mengimpor.",
@@ -1294,5 +1293,18 @@
"Includes your reading progress": "Termasuk progres baca Anda",
"Synced {{time}}": "Disinkronkan {{time}}",
"Sync Info": "Info sinkronisasi",
"Last Synced": "Sinkronisasi terakhir"
"Last Synced": "Sinkronisasi terakhir",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Bundel DICT memerlukan berkas .index dan berkas .dict.dz.",
"Slob bundles need a .slob file.": "Bundel Slob memerlukan berkas .slob.",
"URL template must start with http(s):// and contain %WORD%.": "Templat URL harus diawali http(s):// dan berisi %WORD%.",
"Web": "Web",
"Add Web Search": "Tambah Pencarian Web",
"Edit Web Search": "Edit Pencarian Web",
"e.g. Google": "mis. Google",
"URL Template": "Templat URL",
"Use %WORD% where the looked-up word should appear.": "Gunakan %WORD% di tempat kata yang dicari harus muncul.",
"Open the search result in your browser:": "Buka hasil pencarian di peramban Anda:",
"Open in {{name}}": "Buka di {{name}}"
}
@@ -1252,7 +1252,6 @@
"Importing…": "Importazione…",
"Import Dictionary": "Importa dizionario",
"No dictionaries available.": "Nessun dizionario disponibile.",
"Drag the handle on the left to reorder.": "Trascina la maniglia a sinistra per riordinare.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "I pacchetti StarDict richiedono file .ifo, .idx e .dict.dz (.syn opzionale).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "I pacchetti MDict usano file .mdx; i .mdd associati sono opzionali.",
"Select all the bundle files together when importing.": "Seleziona tutti i file del pacchetto insieme durante limportazione.",
@@ -1340,5 +1339,18 @@
"Includes your reading progress": "Include i tuoi progressi di lettura",
"Synced {{time}}": "Sincronizzato {{time}}",
"Sync Info": "Info di sincronizzazione",
"Last Synced": "Ultima sincronizzazione"
"Last Synced": "Ultima sincronizzazione",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "I bundle DICT richiedono un file .index e un file .dict.dz.",
"Slob bundles need a .slob file.": "I bundle Slob richiedono un file .slob.",
"URL template must start with http(s):// and contain %WORD%.": "Il modello URL deve iniziare con http(s):// e contenere %WORD%.",
"Web": "Web",
"Add Web Search": "Aggiungi ricerca web",
"Edit Web Search": "Modifica ricerca web",
"e.g. Google": "es. Google",
"URL Template": "Modello URL",
"Use %WORD% where the looked-up word should appear.": "Usa %WORD% dove dovrebbe apparire la parola cercata.",
"Open the search result in your browser:": "Apri il risultato della ricerca nel tuo browser:",
"Open in {{name}}": "Apri in {{name}}"
}
@@ -1216,7 +1216,6 @@
"Importing…": "インポート中…",
"Import Dictionary": "辞書をインポート",
"No dictionaries available.": "利用可能な辞書はありません。",
"Drag the handle on the left to reorder.": "左のハンドルをドラッグして並べ替えます。",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict バンドルには .ifo、.idx、.dict.dz ファイルが必要です(.syn は任意)。",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict バンドルは .mdx ファイルを使用します。付随する .mdd ファイルは任意です。",
"Select all the bundle files together when importing.": "インポート時はバンドルのファイルをまとめて選択してください。",
@@ -1294,5 +1293,18 @@
"Includes your reading progress": "読書の進捗を含みます",
"Synced {{time}}": "{{time}}に同期",
"Sync Info": "同期情報",
"Last Synced": "最終同期"
"Last Synced": "最終同期",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT バンドルには .index ファイルと .dict.dz ファイルが必要です。",
"Slob bundles need a .slob file.": "Slob バンドルには .slob ファイルが必要です。",
"URL template must start with http(s):// and contain %WORD%.": "URL テンプレートは http(s):// で始まり、%WORD% を含む必要があります。",
"Web": "ウェブ",
"Add Web Search": "ウェブ検索を追加",
"Edit Web Search": "ウェブ検索を編集",
"e.g. Google": "例: Google",
"URL Template": "URL テンプレート",
"Use %WORD% where the looked-up word should appear.": "調べる単語が表示される場所に %WORD% を使用してください。",
"Open the search result in your browser:": "検索結果をブラウザで開く:",
"Open in {{name}}": "{{name}} で開く"
}
@@ -1216,7 +1216,6 @@
"Importing…": "가져오는 중…",
"Import Dictionary": "사전 가져오기",
"No dictionaries available.": "사용 가능한 사전이 없습니다.",
"Drag the handle on the left to reorder.": "왼쪽 핸들을 드래그하여 순서를 변경하세요.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict 번들은 .ifo, .idx, .dict.dz 파일이 필요합니다(.syn은 선택).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict 번들은 .mdx 파일을 사용하며, 동반되는 .mdd 파일은 선택입니다.",
"Select all the bundle files together when importing.": "가져올 때 번들의 모든 파일을 함께 선택하세요.",
@@ -1294,5 +1293,18 @@
"Includes your reading progress": "읽기 진행률 포함됨",
"Synced {{time}}": "{{time}} 동기화됨",
"Sync Info": "동기화 정보",
"Last Synced": "마지막 동기화"
"Last Synced": "마지막 동기화",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT 번들에는 .index 파일과 .dict.dz 파일이 필요합니다.",
"Slob bundles need a .slob file.": "Slob 번들에는 .slob 파일이 필요합니다.",
"URL template must start with http(s):// and contain %WORD%.": "URL 템플릿은 http(s):// 로 시작하고 %WORD%를 포함해야 합니다.",
"Web": "웹",
"Add Web Search": "웹 검색 추가",
"Edit Web Search": "웹 검색 편집",
"e.g. Google": "예: Google",
"URL Template": "URL 템플릿",
"Use %WORD% where the looked-up word should appear.": "검색어가 표시될 위치에 %WORD%를 사용하세요.",
"Open the search result in your browser:": "브라우저에서 검색 결과 열기:",
"Open in {{name}}": "{{name}}에서 열기"
}
@@ -1216,7 +1216,6 @@
"Importing…": "Mengimport…",
"Import Dictionary": "Import Kamus",
"No dictionaries available.": "Tiada kamus tersedia.",
"Drag the handle on the left to reorder.": "Seret pemegang di sebelah kiri untuk susun semula.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Bundle StarDict memerlukan fail .ifo, .idx dan .dict.dz (.syn pilihan).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Bundle MDict menggunakan fail .mdx; fail .mdd pengiring adalah pilihan.",
"Select all the bundle files together when importing.": "Pilih semua fail bundle sekali gus semasa mengimport.",
@@ -1294,5 +1293,18 @@
"Includes your reading progress": "Termasuk kemajuan bacaan anda",
"Synced {{time}}": "Disegerakkan {{time}}",
"Sync Info": "Maklumat penyegerakan",
"Last Synced": "Penyegerakan terakhir"
"Last Synced": "Penyegerakan terakhir",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Pakej DICT memerlukan fail .index dan fail .dict.dz.",
"Slob bundles need a .slob file.": "Pakej Slob memerlukan fail .slob.",
"URL template must start with http(s):// and contain %WORD%.": "Templat URL mesti bermula dengan http(s):// dan mengandungi %WORD%.",
"Web": "Web",
"Add Web Search": "Tambah Carian Web",
"Edit Web Search": "Edit Carian Web",
"e.g. Google": "cth. Google",
"URL Template": "Templat URL",
"Use %WORD% where the looked-up word should appear.": "Gunakan %WORD% di mana perkataan yang dicari harus muncul.",
"Open the search result in your browser:": "Buka hasil carian dalam pelayar anda:",
"Open in {{name}}": "Buka dalam {{name}}"
}
@@ -1234,7 +1234,6 @@
"Importing…": "Bezig met importeren…",
"Import Dictionary": "Woordenboek importeren",
"No dictionaries available.": "Geen woordenboeken beschikbaar.",
"Drag the handle on the left to reorder.": "Sleep de greep links om opnieuw te ordenen.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict-bundels hebben .ifo-, .idx- en .dict.dz-bestanden nodig (.syn optioneel).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict-bundels gebruiken .mdx-bestanden; bijbehorende .mdd-bestanden zijn optioneel.",
"Select all the bundle files together when importing.": "Selecteer bij het importeren alle bundelbestanden tegelijk.",
@@ -1317,5 +1316,18 @@
"Includes your reading progress": "Inclusief je leesvoortgang",
"Synced {{time}}": "Gesynchroniseerd {{time}}",
"Sync Info": "Synchronisatie-info",
"Last Synced": "Laatst gesynchroniseerd"
"Last Synced": "Laatst gesynchroniseerd",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT-bundels hebben een .index-bestand en een .dict.dz-bestand nodig.",
"Slob bundles need a .slob file.": "Slob-bundels hebben een .slob-bestand nodig.",
"URL template must start with http(s):// and contain %WORD%.": "URL-sjabloon moet beginnen met http(s):// en %WORD% bevatten.",
"Web": "Web",
"Add Web Search": "Webzoekopdracht toevoegen",
"Edit Web Search": "Webzoekopdracht bewerken",
"e.g. Google": "bijv. Google",
"URL Template": "URL-sjabloon",
"Use %WORD% where the looked-up word should appear.": "Gebruik %WORD% op de plek waar het opgezochte woord moet verschijnen.",
"Open the search result in your browser:": "Open het zoekresultaat in je browser:",
"Open in {{name}}": "Openen in {{name}}"
}
@@ -1270,7 +1270,6 @@
"Importing…": "Importowanie…",
"Import Dictionary": "Importuj słownik",
"No dictionaries available.": "Brak dostępnych słowników.",
"Drag the handle on the left to reorder.": "Przeciągnij uchwyt po lewej, aby zmienić kolejność.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Pakiety StarDict wymagają plików .ifo, .idx i .dict.dz (.syn opcjonalny).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Pakiety MDict używają plików .mdx; towarzyszące .mdd są opcjonalne.",
"Select all the bundle files together when importing.": "Podczas importu wybierz wszystkie pliki pakietu jednocześnie.",
@@ -1363,5 +1362,18 @@
"Includes your reading progress": "Zawiera Twój postęp czytania",
"Synced {{time}}": "Zsynchronizowano {{time}}",
"Sync Info": "Informacje o synchronizacji",
"Last Synced": "Ostatnia synchronizacja"
"Last Synced": "Ostatnia synchronizacja",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Pakiety DICT wymagają pliku .index i pliku .dict.dz.",
"Slob bundles need a .slob file.": "Pakiety Slob wymagają pliku .slob.",
"URL template must start with http(s):// and contain %WORD%.": "Szablon URL musi zaczynać się od http(s):// i zawierać %WORD%.",
"Web": "Web",
"Add Web Search": "Dodaj wyszukiwanie internetowe",
"Edit Web Search": "Edytuj wyszukiwanie internetowe",
"e.g. Google": "np. Google",
"URL Template": "Szablon URL",
"Use %WORD% where the looked-up word should appear.": "Użyj %WORD% w miejscu, w którym ma się pojawić wyszukiwane słowo.",
"Open the search result in your browser:": "Otwórz wynik wyszukiwania w przeglądarce:",
"Open in {{name}}": "Otwórz w {{name}}"
}
@@ -1252,7 +1252,6 @@
"Importing…": "Importando…",
"Import Dictionary": "Importar dicionário",
"No dictionaries available.": "Nenhum dicionário disponível.",
"Drag the handle on the left to reorder.": "Arraste o punho à esquerda para reordenar.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Pacotes StarDict precisam dos arquivos .ifo, .idx e .dict.dz (.syn opcional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Pacotes MDict usam arquivos .mdx; arquivos .mdd companheiros são opcionais.",
"Select all the bundle files together when importing.": "Selecione todos os arquivos do pacote juntos ao importar.",
@@ -1340,5 +1339,18 @@
"Includes your reading progress": "Inclui seu progresso de leitura",
"Synced {{time}}": "Sincronizado {{time}}",
"Sync Info": "Informações de sincronização",
"Last Synced": "Última sincronização"
"Last Synced": "Última sincronização",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Os pacotes DICT precisam de um arquivo .index e um arquivo .dict.dz.",
"Slob bundles need a .slob file.": "Os pacotes Slob precisam de um arquivo .slob.",
"URL template must start with http(s):// and contain %WORD%.": "O modelo de URL deve começar com http(s):// e conter %WORD%.",
"Web": "Web",
"Add Web Search": "Adicionar pesquisa web",
"Edit Web Search": "Editar pesquisa web",
"e.g. Google": "por exemplo: Google",
"URL Template": "Modelo de URL",
"Use %WORD% where the looked-up word should appear.": "Use %WORD% onde a palavra pesquisada deve aparecer.",
"Open the search result in your browser:": "Abrir o resultado da pesquisa no seu navegador:",
"Open in {{name}}": "Abrir em {{name}}"
}
@@ -1252,7 +1252,6 @@
"Importing…": "Se importă…",
"Import Dictionary": "Importă dicționar",
"No dictionaries available.": "Niciun dicționar disponibil.",
"Drag the handle on the left to reorder.": "Trage de mâner din stânga pentru a reordona.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Pachetele StarDict au nevoie de fișierele .ifo, .idx și .dict.dz (.syn opțional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Pachetele MDict folosesc fișiere .mdx; fișierele .mdd însoțitoare sunt opționale.",
"Select all the bundle files together when importing.": "Selectează împreună toate fișierele pachetului la import.",
@@ -1340,5 +1339,18 @@
"Includes your reading progress": "Include progresul tău de lectură",
"Synced {{time}}": "Sincronizat {{time}}",
"Sync Info": "Informații sincronizare",
"Last Synced": "Ultima sincronizare"
"Last Synced": "Ultima sincronizare",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Pachetele DICT necesită un fișier .index și un fișier .dict.dz.",
"Slob bundles need a .slob file.": "Pachetele Slob necesită un fișier .slob.",
"URL template must start with http(s):// and contain %WORD%.": "Șablonul URL trebuie să înceapă cu http(s):// și să conțină %WORD%.",
"Web": "Web",
"Add Web Search": "Adaugă căutare web",
"Edit Web Search": "Editează căutarea web",
"e.g. Google": "ex. Google",
"URL Template": "Șablon URL",
"Use %WORD% where the looked-up word should appear.": "Folosiți %WORD% acolo unde ar trebui să apară cuvântul căutat.",
"Open the search result in your browser:": "Deschideți rezultatul căutării în browser:",
"Open in {{name}}": "Deschide în {{name}}"
}
@@ -1270,7 +1270,6 @@
"Importing…": "Импорт…",
"Import Dictionary": "Импорт словаря",
"No dictionaries available.": "Нет доступных словарей.",
"Drag the handle on the left to reorder.": "Перетащите ручку слева для изменения порядка.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Пакеты StarDict требуют файлов .ifo, .idx и .dict.dz (.syn — опционально).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Пакеты MDict используют файлы .mdx; сопутствующие .mdd — опционально.",
"Select all the bundle files together when importing.": "При импорте выбирайте все файлы пакета вместе.",
@@ -1363,5 +1362,18 @@
"Includes your reading progress": "Включает ваш прогресс чтения",
"Synced {{time}}": "Синхронизировано {{time}}",
"Sync Info": "Информация о синхронизации",
"Last Synced": "Последняя синхронизация"
"Last Synced": "Последняя синхронизация",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Пакетам DICT нужны файл .index и файл .dict.dz.",
"Slob bundles need a .slob file.": "Пакетам Slob нужен файл .slob.",
"URL template must start with http(s):// and contain %WORD%.": "Шаблон URL должен начинаться с http(s):// и содержать %WORD%.",
"Web": "Веб",
"Add Web Search": "Добавить веб-поиск",
"Edit Web Search": "Изменить веб-поиск",
"e.g. Google": "Например, Google",
"URL Template": "Шаблон URL",
"Use %WORD% where the looked-up word should appear.": "Используйте %WORD% там, где должно появиться искомое слово.",
"Open the search result in your browser:": "Открыть результат поиска в браузере:",
"Open in {{name}}": "Открыть в {{name}}"
}
@@ -1234,7 +1234,6 @@
"Importing…": "ආයාත වෙමින්…",
"Import Dictionary": "ශබ්දකෝෂයක් ආයාත කරන්න",
"No dictionaries available.": "ලබා ගත හැකි ශබ්දකෝෂ නැත.",
"Drag the handle on the left to reorder.": "නැවත සකස් කිරීමට වම් පස ඇති පතන අදින්න.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict බණ්ඩලවලට .ifo, .idx සහ .dict.dz ගොනු අවශ්‍යය (.syn විකල්ප).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict බණ්ඩල .mdx ගොනු භාවිතා කරයි; සහායක .mdd ගොනු විකල්ප වේ.",
"Select all the bundle files together when importing.": "ආයාත කරන විට බණ්ඩලේ සියලු ගොනු එකවර තෝරන්න.",
@@ -1317,5 +1316,18 @@
"Includes your reading progress": "ඔබේ කියවීමේ ප්‍රගතිය ඇතුළත්",
"Synced {{time}}": "{{time}} සමමුහුර්ත කළේ",
"Sync Info": "සමමුහුර්ත තොරතුරු",
"Last Synced": "අවසන් සමමුහුර්තය"
"Last Synced": "අවසන් සමමුහුර්තය",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT පැකේජ සඳහා .index ගොනුවක් සහ .dict.dz ගොනුවක් අවශ්‍ය වේ.",
"Slob bundles need a .slob file.": "Slob පැකේජ සඳහා .slob ගොනුවක් අවශ්‍ය වේ.",
"URL template must start with http(s):// and contain %WORD%.": "URL අච්චුව http(s):// වලින් ආරම්භ විය යුතු අතර %WORD% අඩංගු විය යුතුය.",
"Web": "වෙබ්",
"Add Web Search": "වෙබ් සෙවුම එක් කරන්න",
"Edit Web Search": "වෙබ් සෙවුම සංස්කරණය කරන්න",
"e.g. Google": "උදා. Google",
"URL Template": "URL අච්චුව",
"Use %WORD% where the looked-up word should appear.": "සොයන වචනය දිස්විය යුතු තැන %WORD% භාවිතා කරන්න.",
"Open the search result in your browser:": "ඔබේ බ්‍රවුසරයේ සෙවුම් ප්‍රතිඵලය විවෘත කරන්න:",
"Open in {{name}}": "{{name}} වල විවෘත කරන්න"
}
@@ -1270,7 +1270,6 @@
"Importing…": "Uvažanje …",
"Import Dictionary": "Uvozi slovar",
"No dictionaries available.": "Ni razpoložljivih slovarjev.",
"Drag the handle on the left to reorder.": "Povleci ročaj levo za prerazporeditev.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Svežnji StarDict potrebujejo datoteke .ifo, .idx in .dict.dz (.syn neobvezno).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Svežnji MDict uporabljajo datoteke .mdx; spremljajoče .mdd so neobvezne.",
"Select all the bundle files together when importing.": "Pri uvozu izberi vse datoteke svežnja skupaj.",
@@ -1363,5 +1362,18 @@
"Includes your reading progress": "Vključuje vaš napredek branja",
"Synced {{time}}": "Sinhronizirano {{time}}",
"Sync Info": "Informacije o sinhronizaciji",
"Last Synced": "Zadnja sinhronizacija"
"Last Synced": "Zadnja sinhronizacija",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Paketi DICT potrebujejo datoteko .index in datoteko .dict.dz.",
"Slob bundles need a .slob file.": "Paketi Slob potrebujejo datoteko .slob.",
"URL template must start with http(s):// and contain %WORD%.": "Predloga URL se mora začeti z http(s):// in vsebovati %WORD%.",
"Web": "Splet",
"Add Web Search": "Dodaj spletno iskanje",
"Edit Web Search": "Uredi spletno iskanje",
"e.g. Google": "npr. Google",
"URL Template": "Predloga URL",
"Use %WORD% where the looked-up word should appear.": "Uporabite %WORD% tam, kjer naj se pojavi iskana beseda.",
"Open the search result in your browser:": "Odprite rezultat iskanja v brskalniku:",
"Open in {{name}}": "Odpri v {{name}}"
}
@@ -1234,7 +1234,6 @@
"Importing…": "Importerar…",
"Import Dictionary": "Importera ordbok",
"No dictionaries available.": "Inga ordböcker tillgängliga.",
"Drag the handle on the left to reorder.": "Dra i handtaget till vänster för att ändra ordning.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict-paket kräver .ifo-, .idx- och .dict.dz-filer (.syn valfri).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict-paket använder .mdx-filer; tillhörande .mdd-filer är valfria.",
"Select all the bundle files together when importing.": "Markera alla paketfiler tillsammans när du importerar.",
@@ -1317,5 +1316,18 @@
"Includes your reading progress": "Inkluderar ditt läsförlopp",
"Synced {{time}}": "Synkroniserades {{time}}",
"Sync Info": "Synkroniseringsinformation",
"Last Synced": "Senast synkroniserad"
"Last Synced": "Senast synkroniserad",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT-paket kräver en .index-fil och en .dict.dz-fil.",
"Slob bundles need a .slob file.": "Slob-paket kräver en .slob-fil.",
"URL template must start with http(s):// and contain %WORD%.": "URL-mallen måste börja med http(s):// och innehålla %WORD%.",
"Web": "Webb",
"Add Web Search": "Lägg till webbsökning",
"Edit Web Search": "Redigera webbsökning",
"e.g. Google": "t.ex. Google",
"URL Template": "URL-mall",
"Use %WORD% where the looked-up word should appear.": "Använd %WORD% där det uppslagna ordet ska visas.",
"Open the search result in your browser:": "Öppna sökresultatet i din webbläsare:",
"Open in {{name}}": "Öppna i {{name}}"
}
@@ -1234,7 +1234,6 @@
"Importing…": "இறக்குமதி செய்கிறது…",
"Import Dictionary": "அகராதியை இறக்குமதி செய்",
"No dictionaries available.": "அகராதிகள் கிடைக்கவில்லை.",
"Drag the handle on the left to reorder.": "மறு வரிசைப்படுத்த இடதுபுறம் உள்ள கைப்பிடியை இழுக்கவும்.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict தொகுப்புகளுக்கு .ifo, .idx, .dict.dz கோப்புகள் தேவை (.syn விருப்பம்).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict தொகுப்புகள் .mdx கோப்புகளைப் பயன்படுத்தும்; தொடர்புடைய .mdd கோப்புகள் விருப்பம்.",
"Select all the bundle files together when importing.": "இறக்குமதி செய்யும்போது தொகுப்பின் அனைத்துக் கோப்புகளையும் ஒரே நேரத்தில் தேர்ந்தெடுக்கவும்.",
@@ -1317,5 +1316,18 @@
"Includes your reading progress": "உங்கள் வாசிப்பு முன்னேற்றத்தை உள்ளடக்கியது",
"Synced {{time}}": "{{time}} ஒத்திசைக்கப்பட்டது",
"Sync Info": "ஒத்திசைவு தகவல்",
"Last Synced": "கடைசி ஒத்திசைவு"
"Last Synced": "கடைசி ஒத்திசைவு",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT தொகுப்புகளுக்கு ஒரு .index கோப்பு மற்றும் ஒரு .dict.dz கோப்பு தேவை.",
"Slob bundles need a .slob file.": "Slob தொகுப்புகளுக்கு ஒரு .slob கோப்பு தேவை.",
"URL template must start with http(s):// and contain %WORD%.": "URL வார்ப்புரு http(s):// உடன் தொடங்க வேண்டும் மற்றும் %WORD% இருக்க வேண்டும்.",
"Web": "வலை",
"Add Web Search": "வலைத் தேடல் சேர்",
"Edit Web Search": "வலைத் தேடலைத் திருத்து",
"e.g. Google": "எ.கா. Google",
"URL Template": "URL வார்ப்புரு",
"Use %WORD% where the looked-up word should appear.": "தேடப்பட்ட சொல் தோன்ற வேண்டிய இடத்தில் %WORD% ஐ பயன்படுத்தவும்.",
"Open the search result in your browser:": "உங்கள் உலாவியில் தேடல் முடிவைத் திற:",
"Open in {{name}}": "{{name}} இல் திற"
}
@@ -1216,7 +1216,6 @@
"Importing…": "กำลังนำเข้า…",
"Import Dictionary": "นำเข้าพจนานุกรม",
"No dictionaries available.": "ไม่มีพจนานุกรม",
"Drag the handle on the left to reorder.": "ลากที่จับทางซ้ายเพื่อจัดลำดับใหม่",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "บันเดิล StarDict ต้องมีไฟล์ .ifo, .idx และ .dict.dz (.syn ไม่บังคับ)",
"MDict bundles use .mdx files; companion .mdd files are optional.": "บันเดิล MDict ใช้ไฟล์ .mdx ส่วนไฟล์ .mdd ที่มาด้วยเป็นทางเลือก",
"Select all the bundle files together when importing.": "เลือกไฟล์ทั้งหมดของบันเดิลพร้อมกันเมื่อทำการนำเข้า",
@@ -1294,5 +1293,18 @@
"Includes your reading progress": "รวมถึงความคืบหน้าการอ่านของคุณ",
"Synced {{time}}": "ซิงค์เมื่อ {{time}}",
"Sync Info": "ข้อมูลการซิงค์",
"Last Synced": "ซิงค์ล่าสุด"
"Last Synced": "ซิงค์ล่าสุด",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "ชุด DICT ต้องการไฟล์ .index และไฟล์ .dict.dz",
"Slob bundles need a .slob file.": "ชุด Slob ต้องการไฟล์ .slob",
"URL template must start with http(s):// and contain %WORD%.": "เทมเพลต URL ต้องขึ้นต้นด้วย http(s):// และมี %WORD%",
"Web": "เว็บ",
"Add Web Search": "เพิ่มการค้นหาบนเว็บ",
"Edit Web Search": "แก้ไขการค้นหาบนเว็บ",
"e.g. Google": "เช่น Google",
"URL Template": "เทมเพลต URL",
"Use %WORD% where the looked-up word should appear.": "ใช้ %WORD% ในตำแหน่งที่คำค้นหาควรปรากฏ",
"Open the search result in your browser:": "เปิดผลลัพธ์การค้นหาในเบราว์เซอร์ของคุณ:",
"Open in {{name}}": "เปิดใน {{name}}"
}
@@ -1234,7 +1234,6 @@
"Importing…": "İçe aktarılıyor…",
"Import Dictionary": "Sözlük içe aktar",
"No dictionaries available.": "Kullanılabilir sözlük yok.",
"Drag the handle on the left to reorder.": "Yeniden sıralamak için soldaki tutamacı sürükleyin.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict paketleri .ifo, .idx ve .dict.dz dosyalarını gerektirir (.syn isteğe bağlı).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict paketleri .mdx dosyalarını kullanır; eşlik eden .mdd dosyaları isteğe bağlıdır.",
"Select all the bundle files together when importing.": "İçe aktarırken paketin tüm dosyalarını birlikte seçin.",
@@ -1317,5 +1316,18 @@
"Includes your reading progress": "Okuma ilerlemenizi içerir",
"Synced {{time}}": "{{time}} senkronize edildi",
"Sync Info": "Senkronizasyon bilgisi",
"Last Synced": "Son senkronizasyon"
"Last Synced": "Son senkronizasyon",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT paketleri bir .index dosyası ve bir .dict.dz dosyası gerektirir.",
"Slob bundles need a .slob file.": "Slob paketleri bir .slob dosyası gerektirir.",
"URL template must start with http(s):// and contain %WORD%.": "URL şablonu http(s):// ile başlamalı ve %WORD% içermelidir.",
"Web": "Web",
"Add Web Search": "Web Araması Ekle",
"Edit Web Search": "Web Aramasını Düzenle",
"e.g. Google": "örn. Google",
"URL Template": "URL Şablonu",
"Use %WORD% where the looked-up word should appear.": "Aranan kelimenin görünmesi gereken yerde %WORD% kullanın.",
"Open the search result in your browser:": "Arama sonucunu tarayıcınızda açın:",
"Open in {{name}}": "{{name}} ile aç"
}
@@ -1270,7 +1270,6 @@
"Importing…": "Імпорт…",
"Import Dictionary": "Імпорт словника",
"No dictionaries available.": "Немає доступних словників.",
"Drag the handle on the left to reorder.": "Перетягуйте маркер ліворуч, щоб змінити порядок.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Пакети StarDict потребують файлів .ifo, .idx і .dict.dz (.syn — необов’язково).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Пакети MDict використовують файли .mdx; супутні .mdd — необов’язково.",
"Select all the bundle files together when importing.": "Під час імпорту вибирайте всі файли пакета разом.",
@@ -1363,5 +1362,18 @@
"Includes your reading progress": "Містить ваш прогрес читання",
"Synced {{time}}": "Синхронізовано {{time}}",
"Sync Info": "Інформація про синхронізацію",
"Last Synced": "Остання синхронізація"
"Last Synced": "Остання синхронізація",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Пакети DICT потребують файл .index і файл .dict.dz.",
"Slob bundles need a .slob file.": "Пакети Slob потребують файл .slob.",
"URL template must start with http(s):// and contain %WORD%.": "Шаблон URL має починатися з http(s):// та містити %WORD%.",
"Web": "Веб",
"Add Web Search": "Додати веб-пошук",
"Edit Web Search": "Редагувати веб-пошук",
"e.g. Google": "напр. Google",
"URL Template": "Шаблон URL",
"Use %WORD% where the looked-up word should appear.": "Використовуйте %WORD% там, де має з'явитися шукане слово.",
"Open the search result in your browser:": "Відкрийте результат пошуку в браузері:",
"Open in {{name}}": "Відкрити в {{name}}"
}
@@ -1216,7 +1216,6 @@
"Importing…": "Đang nhập…",
"Import Dictionary": "Nhập từ điển",
"No dictionaries available.": "Không có từ điển.",
"Drag the handle on the left to reorder.": "Kéo tay cầm bên trái để sắp xếp lại.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Gói StarDict cần các tệp .ifo, .idx và .dict.dz (.syn tuỳ chọn).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Gói MDict dùng tệp .mdx; tệp .mdd đi kèm là tuỳ chọn.",
"Select all the bundle files together when importing.": "Chọn tất cả các tệp của gói cùng lúc khi nhập.",
@@ -1294,5 +1293,18 @@
"Includes your reading progress": "Bao gồm tiến độ đọc của bạn",
"Synced {{time}}": "Đã đồng bộ {{time}}",
"Sync Info": "Thông tin đồng bộ",
"Last Synced": "Đồng bộ lần cuối"
"Last Synced": "Đồng bộ lần cuối",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "Gói DICT cần một tệp .index và một tệp .dict.dz.",
"Slob bundles need a .slob file.": "Gói Slob cần một tệp .slob.",
"URL template must start with http(s):// and contain %WORD%.": "Mẫu URL phải bắt đầu bằng http(s):// và chứa %WORD%.",
"Web": "Web",
"Add Web Search": "Thêm tìm kiếm web",
"Edit Web Search": "Sửa tìm kiếm web",
"e.g. Google": "vd. Google",
"URL Template": "Mẫu URL",
"Use %WORD% where the looked-up word should appear.": "Dùng %WORD% tại vị trí mà từ tra cứu sẽ xuất hiện.",
"Open the search result in your browser:": "Mở kết quả tìm kiếm trong trình duyệt của bạn:",
"Open in {{name}}": "Mở trong {{name}}"
}
@@ -1216,7 +1216,6 @@
"Importing…": "正在导入…",
"Import Dictionary": "导入词典",
"No dictionaries available.": "没有可用的词典。",
"Drag the handle on the left to reorder.": "拖动左侧手柄以重新排序。",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict 词典包需要 .ifo、.idx 和 .dict.dz 文件(.syn 可选)。",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict 词典包使用 .mdx 文件;配套的 .mdd 文件为可选。",
"Select all the bundle files together when importing.": "导入时请同时选择词典包内的所有文件。",
@@ -1294,5 +1293,18 @@
"Includes your reading progress": "包含你的阅读进度",
"Synced {{time}}": "{{time}}已同步",
"Sync Info": "同步信息",
"Last Synced": "上一次同步"
"Last Synced": "上一次同步",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT 词典包需要一个 .index 文件和一个 .dict.dz 文件。",
"Slob bundles need a .slob file.": "Slob 词典包需要一个 .slob 文件。",
"URL template must start with http(s):// and contain %WORD%.": "URL 模板必须以 http(s):// 开头并包含 %WORD%。",
"Web": "网络",
"Add Web Search": "添加网络搜索",
"Edit Web Search": "编辑网络搜索",
"e.g. Google": "例如 Google",
"URL Template": "URL 模板",
"Use %WORD% where the looked-up word should appear.": "在查询词应出现的位置使用 %WORD%。",
"Open the search result in your browser:": "在浏览器中打开搜索结果:",
"Open in {{name}}": "在 {{name}} 中打开"
}
@@ -1216,7 +1216,6 @@
"Importing…": "正在匯入…",
"Import Dictionary": "匯入詞典",
"No dictionaries available.": "沒有可用的詞典。",
"Drag the handle on the left to reorder.": "拖曳左側握把以重新排序。",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict 詞典套件需要 .ifo、.idx 和 .dict.dz 檔案(.syn 選用)。",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict 詞典套件使用 .mdx 檔案;搭配的 .mdd 檔案為選用。",
"Select all the bundle files together when importing.": "匯入時請同時選擇詞典套件內的所有檔案。",
@@ -1294,5 +1293,18 @@
"Includes your reading progress": "包含你的閱讀進度",
"Synced {{time}}": "{{time}}已同步",
"Sync Info": "同步資訊",
"Last Synced": "上一次同步"
"Last Synced": "上一次同步",
"DICT": "DICT",
"Slob": "Slob",
"DICT bundles need a .index file and a .dict.dz file.": "DICT 字典套件需要一個 .index 檔案和一個 .dict.dz 檔案。",
"Slob bundles need a .slob file.": "Slob 字典套件需要一個 .slob 檔案。",
"URL template must start with http(s):// and contain %WORD%.": "URL 範本必須以 http(s):// 開頭並包含 %WORD%。",
"Web": "網路",
"Add Web Search": "新增網路搜尋",
"Edit Web Search": "編輯網路搜尋",
"e.g. Google": "例如 Google",
"URL Template": "URL 範本",
"Use %WORD% where the looked-up word should appear.": "在查詢字詞應出現的位置使用 %WORD%。",
"Open the search result in your browser:": "在瀏覽器中開啟搜尋結果:",
"Open in {{name}}": "在 {{name}} 中開啟"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
/**
* Shared DICT (dictd) test fixture loader.
*
* Points at the real FreeDict English-Dutch bundle in
* `src/__tests__/fixtures/data/dicts/`. 7,720 entries, dictzip-compressed
* body, plain UTF-8 plain-text definitions small enough for fast tests
* but large enough to exercise multi-chunk reads.
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/data/dicts');
export const INDEX_FIXTURE_PATH = path.join(FIXTURES_DIR, 'freedict-eng-nld.index');
export const DICT_FIXTURE_PATH = path.join(FIXTURES_DIR, 'freedict-eng-nld.dict.dz');
export const INDEX_FIXTURE_NAME = 'freedict-eng-nld.index';
export const DICT_FIXTURE_NAME = 'freedict-eng-nld.dict.dz';
async function readAsFile(filePath: string, name: string): Promise<File> {
const bytes = await readFile(filePath);
const buf = new Uint8Array(bytes.length);
buf.set(bytes);
return new File([buf], name);
}
export const readIndexFile = () => readAsFile(INDEX_FIXTURE_PATH, INDEX_FIXTURE_NAME);
export const readDictFile = () => readAsFile(DICT_FIXTURE_PATH, DICT_FIXTURE_NAME);
@@ -0,0 +1,24 @@
/**
* Shared Slob (Aard 2) test fixture loader.
*
* Points at the real FreeDict English-Dutch slob in
* `src/__tests__/fixtures/data/dicts/`. ~7,700 refs, zlib-compressed bins,
* `text/html;charset=utf-8` content for headwords and `text/css` for the
* bundled stylesheet (`~/css/default.css`).
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/data/dicts');
export const SLOB_FIXTURE_PATH = path.join(FIXTURES_DIR, 'eng-nld.slob');
export const SLOB_FIXTURE_NAME = 'eng-nld.slob';
async function readAsFile(filePath: string, name: string): Promise<File> {
const bytes = await readFile(filePath);
const buf = new Uint8Array(bytes.length);
buf.set(bytes);
return new File([buf], name);
}
export const readSlobFile = () => readAsFile(SLOB_FIXTURE_PATH, SLOB_FIXTURE_NAME);
@@ -0,0 +1,348 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { gzipSync } from 'node:zlib';
import { createDictProvider } from '@/services/dictionaries/providers/dictProvider';
import { decodeDictBase64, parseDictIndex } from '@/services/dictionaries/dictReader';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { BaseDir } from '@/types/system';
import {
INDEX_FIXTURE_NAME,
DICT_FIXTURE_NAME,
readIndexFile,
readDictFile,
} from './_dictFixtures';
import { makeReadCounter, withReadCounting } from './_countingFs';
// ---------------------------------------------------------------------------
// Base64 alphabet decoder unit tests.
// ---------------------------------------------------------------------------
describe('decodeDictBase64', () => {
it('decodes single-character tokens', () => {
expect(decodeDictBase64('A')).toBe(0);
expect(decodeDictBase64('B')).toBe(1);
expect(decodeDictBase64('Z')).toBe(25);
expect(decodeDictBase64('a')).toBe(26);
expect(decodeDictBase64('z')).toBe(51);
expect(decodeDictBase64('0')).toBe(52);
expect(decodeDictBase64('9')).toBe(61);
expect(decodeDictBase64('+')).toBe(62);
expect(decodeDictBase64('/')).toBe(63);
});
it('decodes multi-character big-endian tokens', () => {
// 'BA' = 1·64 + 0 = 64
expect(decodeDictBase64('BA')).toBe(64);
// 'Bn0B' = 1·64³ + 39·64² + 52·64 + 1 = 262144+159744+3328+1 = 425217
expect(decodeDictBase64('Bn0B')).toBe(425217);
});
it('rejects characters outside the alphabet', () => {
expect(() => decodeDictBase64('A!B')).toThrow(/Invalid DICT base64/);
});
});
// ---------------------------------------------------------------------------
// Synthetic-bundle tests — minimal DICT bundle exercising parser edge cases.
// ---------------------------------------------------------------------------
const A64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function encodeDictBase64(n: number): string {
if (n === 0) return 'A';
let s = '';
while (n > 0) {
s = A64[n & 63] + s;
n = Math.floor(n / 64);
}
return s;
}
interface SynEntry {
word: string;
text: string;
}
function buildBundle(
entries: SynEntry[],
meta: { short?: string; info?: string; url?: string } = {},
) {
// Concatenate bodies and remember offsets.
const enc = new TextEncoder();
const bodyParts: Uint8Array[] = [];
const lines: string[] = [];
let pos = 0;
const push = (word: string, text: string) => {
const data = enc.encode(text);
lines.push(`${word}\t${encodeDictBase64(pos)}\t${encodeDictBase64(data.length)}`);
bodyParts.push(data);
pos += data.length;
};
if (meta.short) push('00databaseshort', meta.short);
if (meta.info) push('00databaseinfo', meta.info);
if (meta.url) push('00databaseurl', meta.url);
push('00databaseutf8', '');
for (const e of entries) push(e.word, e.text);
// Concatenate body and gzip it as a (non-DictZip) gzip stream — the
// reader's whole-file gunzip fallback handles that.
const body = new Uint8Array(bodyParts.reduce((n, p) => n + p.length, 0));
let off = 0;
for (const p of bodyParts) {
body.set(p, off);
off += p.length;
}
const dictDz = new Uint8Array(gzipSync(Buffer.from(body)));
// .index: deliberately keep the original input order (FreeDict files in
// the wild are mostly sorted by `dictsort`, but the parser must still
// re-sort defensively before binary search).
const indexBytes = enc.encode(lines.join('\n') + '\n');
return { index: indexBytes, dictDz };
}
describe('parseDictIndex', () => {
it('handles CRLF and trailing whitespace', () => {
const text = ['hello\tA\tB', '00databaseshort\tA\tA', 'world\tB\tA', ''].join('\r\n');
const parsed = parseDictIndex(text);
expect(parsed.words).toEqual(['hello', 'world']);
expect(parsed.meta['00databaseshort']).toEqual({ offset: 0, size: 0 });
});
it('sorts entries case-insensitively', () => {
const text = ['Banana\tA\tA', 'apple\tB\tA', 'Cherry\tC\tA'].join('\n');
const parsed = parseDictIndex(text);
expect(parsed.words).toEqual(['apple', 'Banana', 'Cherry']);
});
});
describe('dictProvider — synthetic bundle', () => {
let bundleFiles: { index: Uint8Array; dictDz: Uint8Array };
beforeEach(() => {
bundleFiles = buildBundle(
[
{ word: 'apple', text: 'a fruit' },
{ word: 'banana', text: 'another fruit' },
{ word: 'cherry', text: 'a small red fruit' },
],
{ short: 'Test Dict', info: 'A tiny test dictionary.' },
);
});
const buildDict = (): ImportedDictionary => ({
id: 'dict:test',
kind: 'dict',
name: 'Test Dict',
bundleDir: 'test-bundle',
files: { index: 'test.index', dict: 'test.dict.dz' },
addedAt: 1,
});
const makeFs = (files = bundleFiles) => ({
openFile: async (path: string, _base: BaseDir) => {
const base = path.split('/').pop()!;
let bytes: Uint8Array;
if (base === 'test.index') bytes = files.index;
else if (base === 'test.dict.dz') bytes = files.dictDz;
else throw new Error(`Unknown fixture file: ${base}`);
const buf = new Uint8Array(bytes.length);
buf.set(bytes);
return new File([buf], base);
},
});
it('initializes lazily and looks up a headword', async () => {
const provider = createDictProvider({ dict: buildDict(), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('banana', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
if (outcome.ok) {
expect(outcome.headword).toBe('banana');
expect(outcome.sourceLabel).toBe('Test Dict');
}
expect(container.querySelector('h1')?.textContent).toBe('banana');
expect(container.querySelector('pre')?.textContent).toBe('another fruit');
});
it('case-insensitive lookup', async () => {
const provider = createDictProvider({ dict: buildDict(), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('APPLE', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
if (outcome.ok) expect(outcome.headword).toBe('apple');
});
it('returns empty when the headword does not exist', async () => {
const provider = createDictProvider({ dict: buildDict(), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('zzzz', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('empty');
});
it('reports `error` on init failure', async () => {
const failingFs = {
openFile: async () => {
throw new Error('disk gone');
},
};
const provider = createDictProvider({ dict: buildDict(), fs: failingFs });
const container = document.createElement('div');
const outcome = await provider.lookup('apple', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) {
expect(outcome.reason).toBe('error');
expect(outcome.message).toContain('disk gone');
}
});
it('shares the parsed instance across lookups', async () => {
const fs = makeFs();
let openCount = 0;
const wrappedFs = {
openFile: async (path: string, base: BaseDir) => {
openCount += 1;
return fs.openFile(path, base);
},
};
const provider = createDictProvider({ dict: buildDict(), fs: wrappedFs });
const container = document.createElement('div');
await provider.lookup('apple', { signal: new AbortController().signal, container });
const opensAfterFirst = openCount;
container.replaceChildren();
await provider.lookup('banana', { signal: new AbortController().signal, container });
expect(openCount).toBe(opensAfterFirst);
});
});
// ---------------------------------------------------------------------------
// Real-fixture tests — the FreeDict English-Dutch bundle.
// ---------------------------------------------------------------------------
describe('dictProvider — real freedict-eng-nld fixture', () => {
const realDict: ImportedDictionary = {
id: 'dict:freedict-eng-nld',
kind: 'dict',
name: 'FreeDict eng-nld',
bundleDir: 'freedict-eng-nld-bundle',
files: { index: INDEX_FIXTURE_NAME, dict: DICT_FIXTURE_NAME },
addedAt: 1,
};
const makeRealFs = () => ({
openFile: async (p: string, _base: BaseDir) => {
const base = p.split('/').pop()!;
if (base === INDEX_FIXTURE_NAME) return readIndexFile();
if (base === DICT_FIXTURE_NAME) return readDictFile();
throw new Error(`Unknown fixture file: ${base}`);
},
});
it('looks up a real headword and renders a definition', async () => {
const provider = createDictProvider({ dict: realDict, fs: makeRealFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('hello', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
if (outcome.ok) {
expect(outcome.headword).toBe('hello');
// 00databaseshort exists in this fixture and is used as the source label.
expect(outcome.sourceLabel).toBeTruthy();
}
expect(container.querySelector('h1')?.textContent).toBe('hello');
const def = container.querySelector('pre')?.textContent ?? '';
expect(def.length).toBeGreaterThan(0);
});
it('finds several common headwords', async () => {
const provider = createDictProvider({ dict: realDict, fs: makeRealFs() });
for (const word of ['hello', 'world', 'reveal']) {
const container = document.createElement('div');
const outcome = await provider.lookup(word, {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
if (outcome.ok) expect(outcome.headword).toBe(word);
expect(container.querySelector('pre')?.textContent?.length ?? 0).toBeGreaterThan(0);
}
});
it('returns empty for non-existent words', async () => {
const provider = createDictProvider({ dict: realDict, fs: makeRealFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('zzznonsenseword', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('empty');
});
it('caches the reader instance — second lookup opens no extra files', async () => {
const realFs = makeRealFs();
let opens = 0;
const wrappedFs = {
openFile: async (p: string, base: BaseDir) => {
opens += 1;
return realFs.openFile(p, base);
},
};
const provider = createDictProvider({ dict: realDict, fs: wrappedFs });
const c1 = document.createElement('div');
await provider.lookup('hello', { signal: new AbortController().signal, container: c1 });
const opensAfterInit = opens;
expect(opensAfterInit).toBe(2); // .index, .dict.dz
const c2 = document.createElement('div');
await provider.lookup('world', { signal: new AbortController().signal, container: c2 });
expect(opens).toBe(opensAfterInit);
});
// -------------------------------------------------------------------------
// Per-lookup .dict.dz reads should be small (lazy chunk decompression).
// -------------------------------------------------------------------------
it('per-lookup .dict.dz reads are bounded by the chunk size', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeRealFs(), counter);
const provider = createDictProvider({ dict: realDict, fs });
// Prime init.
await provider.lookup('hello', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
let dictLast = counter.perFile.get(DICT_FIXTURE_NAME) ?? 0;
for (const word of ['world', 'reveal', 'vital']) {
await provider.lookup(word, {
signal: new AbortController().signal,
container: document.createElement('div'),
});
const dictNow = counter.perFile.get(DICT_FIXTURE_NAME) ?? 0;
const delta = dictNow - dictLast;
// The freedict bundle's dictzip chunks are ~8 KB compressed; bound
// generously at 64 KB to absorb format variance.
expect(delta).toBeLessThanOrEqual(64 * 1024);
dictLast = dictNow;
}
});
});
@@ -1,7 +1,11 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getEnabledProviders, __resetRegistryForTests } from '@/services/dictionaries/registry';
import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
import type { DictionarySettings, ImportedDictionary } from '@/services/dictionaries/types';
import { BUILTIN_PROVIDER_IDS, BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types';
import type {
DictionarySettings,
ImportedDictionary,
WebSearchEntry,
} from '@/services/dictionaries/types';
const baseSettings: DictionarySettings = {
providerOrder: [BUILTIN_PROVIDER_IDS.wiktionary, BUILTIN_PROVIDER_IDS.wikipedia],
@@ -58,6 +62,56 @@ describe('dictionary registry', () => {
expect(a[0]).toBe(b[0]);
});
it('dispatches built-in web-search ids to a web provider', () => {
const settings: DictionarySettings = {
providerOrder: [BUILTIN_WEB_SEARCH_IDS.google, BUILTIN_WEB_SEARCH_IDS.urban],
providerEnabled: {
[BUILTIN_WEB_SEARCH_IDS.google]: true,
[BUILTIN_WEB_SEARCH_IDS.urban]: true,
},
};
const providers = getEnabledProviders({ settings, dictionaries: [] });
expect(providers.map((p) => p.id)).toEqual([
BUILTIN_WEB_SEARCH_IDS.google,
BUILTIN_WEB_SEARCH_IDS.urban,
]);
expect(providers.every((p) => p.kind === 'web')).toBe(true);
});
it('resolves custom web-search ids from settings.webSearches', () => {
const customEntry: WebSearchEntry = {
id: 'web:custom123',
name: 'My Site',
urlTemplate: 'https://example.com/?q=%WORD%',
};
const settings: DictionarySettings = {
providerOrder: ['web:custom123'],
providerEnabled: { 'web:custom123': true },
webSearches: [customEntry],
};
const providers = getEnabledProviders({ settings, dictionaries: [] });
expect(providers).toHaveLength(1);
expect(providers[0]!.label).toBe('My Site');
expect(providers[0]!.kind).toBe('web');
});
it('drops custom web-search ids whose entries are missing or soft-deleted', () => {
const settings: DictionarySettings = {
providerOrder: ['web:gone', 'web:dead'],
providerEnabled: { 'web:gone': true, 'web:dead': true },
webSearches: [
{
id: 'web:dead',
name: 'Dead',
urlTemplate: 'https://example.com/?q=%WORD%',
deletedAt: 1,
},
],
};
const providers = getEnabledProviders({ settings, dictionaries: [] });
expect(providers).toEqual([]);
});
it('skips imported dictionaries that are unavailable, deleted, or unsupported', () => {
const fs = { openFile: async () => new File([], '') };
const dicts: ImportedDictionary[] = [
@@ -0,0 +1,186 @@
import { describe, it, expect } from 'vitest';
import { createSlobProvider } from '@/services/dictionaries/providers/slobProvider';
import { SlobReader, cleanTag } from '@/services/dictionaries/slobReader';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { BaseDir } from '@/types/system';
import { SLOB_FIXTURE_NAME, readSlobFile } from './_slobFixtures';
import { makeReadCounter, withReadCounting } from './_countingFs';
// ---------------------------------------------------------------------------
// Real-fixture tests — the FreeDict English-Dutch slob (zlib-compressed).
// ---------------------------------------------------------------------------
describe('slobReader — real eng-nld.slob fixture', () => {
it('parses the header and exposes metadata', async () => {
const file = await readSlobFile();
const reader = new SlobReader();
await reader.load({ slob: file });
expect(reader.header.encoding).toBe('utf-8');
expect(reader.header.compression).toBe('zlib');
// FreeDict bundles ship 7,717 refs (3 resource refs + 7,714 headwords).
expect(reader.refCount).toBe(7717);
expect(reader.header.contentTypes).toContain('text/html;charset=utf-8');
expect(reader.header.contentTypes).toContain('text/css');
expect(cleanTag(reader.header.tags['label'] ?? '')).toBe('English-Dutch FreeDict Dictionary');
});
it('finds a known headword case-insensitively', async () => {
const file = await readSlobFile();
const reader = new SlobReader();
await reader.load({ slob: file });
const ref = await reader.findRef('hello');
expect(ref).toBeDefined();
if (ref) {
expect(ref.key).toBe('hello');
expect(ref.binIndex).toBeGreaterThanOrEqual(0);
}
const refUpper = await reader.findRef('HELLO');
expect(refUpper?.key).toBe('hello');
});
it('decompresses a bin and reads the html blob', async () => {
const file = await readSlobFile();
const reader = new SlobReader();
await reader.load({ slob: file });
const ref = await reader.findRef('hello');
expect(ref).toBeDefined();
if (!ref) return;
const blob = await reader.readBlob(ref);
expect(blob.contentType.startsWith('text/html')).toBe(true);
const html = new TextDecoder('utf-8').decode(blob.data);
expect(html.length).toBeGreaterThan(0);
// FreeDict slob entries are wrapped in HTML; just sanity-check there's tags.
expect(html).toMatch(/<\/?\w+/);
});
it('returns undefined for non-existent words', async () => {
const file = await readSlobFile();
const reader = new SlobReader();
await reader.load({ slob: file });
expect(await reader.findRef('zzznonsenseword')).toBeUndefined();
});
});
describe('slobReader — error handling', () => {
it('rejects a wrong-magic file', async () => {
const file = new File([new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0, 0, 0, 0])], 'bad.slob');
const reader = new SlobReader();
await expect(reader.load({ slob: file })).rejects.toThrow(/bad magic/);
});
});
// ---------------------------------------------------------------------------
// Provider-level tests — full popup flow.
// ---------------------------------------------------------------------------
describe('slobProvider — real eng-nld.slob fixture', () => {
const realDict: ImportedDictionary = {
id: 'slob:eng-nld',
kind: 'slob',
name: 'English-Dutch FreeDict Dictionary',
bundleDir: 'eng-nld-slob-bundle',
files: { slob: SLOB_FIXTURE_NAME },
addedAt: 1,
};
const makeRealFs = () => ({
openFile: async (p: string, _base: BaseDir) => {
const base = p.split('/').pop()!;
if (base === SLOB_FIXTURE_NAME) return readSlobFile();
throw new Error(`Unknown fixture file: ${base}`);
},
});
it('looks up a real headword and renders HTML', async () => {
const provider = createSlobProvider({ dict: realDict, fs: makeRealFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('hello', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
if (outcome.ok) {
expect(outcome.headword).toBe('hello');
expect(outcome.sourceLabel).toBe('English-Dutch FreeDict Dictionary');
}
expect(container.querySelector('h1')?.textContent).toBe('hello');
// Body div must contain at least one element (the HTML body of the entry).
const body = container.querySelector('div');
expect(body).toBeTruthy();
expect((body?.innerHTML ?? '').length).toBeGreaterThan(0);
});
it('returns empty for missing headwords', async () => {
const provider = createSlobProvider({ dict: realDict, fs: makeRealFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('zzznotaword', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('empty');
});
it('hides bundled resource keys (~/...)', async () => {
const provider = createSlobProvider({ dict: realDict, fs: makeRealFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('~/css/default.css', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('empty');
});
it('caches the reader — second lookup opens no extra files', async () => {
const realFs = makeRealFs();
let opens = 0;
const wrappedFs = {
openFile: async (p: string, base: BaseDir) => {
opens += 1;
return realFs.openFile(p, base);
},
};
const provider = createSlobProvider({ dict: realDict, fs: wrappedFs });
await provider.lookup('hello', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
const opensAfterInit = opens;
expect(opensAfterInit).toBe(1);
await provider.lookup('world', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
expect(opens).toBe(opensAfterInit);
});
it('per-lookup reads are bounded (lazy bin decompression)', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeRealFs(), counter);
const provider = createSlobProvider({ dict: realDict, fs });
// Prime init + one lookup.
await provider.lookup('hello', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
const sizes: number[] = [];
let last = counter.perFile.get(SLOB_FIXTURE_NAME) ?? 0;
for (const word of ['world', 'cat', 'apple', 'about']) {
await provider.lookup(word, {
signal: new AbortController().signal,
container: document.createElement('div'),
});
const now = counter.perFile.get(SLOB_FIXTURE_NAME) ?? 0;
sizes.push(now - last);
last = now;
}
// Each subsequent lookup should be tiny — log2(7717)≈13 ref probes,
// each ≤4 KB, plus at most one new bin decompression. Bound at 256 KB.
for (const delta of sizes) expect(delta).toBeLessThanOrEqual(256 * 1024);
});
});
@@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest';
import { createWebSearchProvider } from '@/services/dictionaries/providers/webSearchProvider';
import {
BUILTIN_WEB_SEARCHES,
getBuiltinWebSearch,
isValidUrlTemplate,
substituteUrlTemplate,
} from '@/services/dictionaries/webSearchTemplates';
import type { WebSearchEntry } from '@/services/dictionaries/types';
describe('substituteUrlTemplate', () => {
it('replaces %WORD% (case-insensitive) with the URL-encoded word', () => {
expect(substituteUrlTemplate('https://x.com/?q=%WORD%', 'hello')).toBe(
'https://x.com/?q=hello',
);
expect(substituteUrlTemplate('https://x.com/?q=%word%', 'hello')).toBe(
'https://x.com/?q=hello',
);
});
it('URL-encodes spaces and special characters', () => {
expect(substituteUrlTemplate('https://x.com/?q=%WORD%', 'hello world')).toBe(
'https://x.com/?q=hello%20world',
);
expect(substituteUrlTemplate('https://x.com/?q=%WORD%', 'a&b?c')).toBe(
'https://x.com/?q=a%26b%3Fc',
);
});
it('handles double-encoded %25WORD%25', () => {
expect(substituteUrlTemplate('https://x.com/?q=%25WORD%25', 'hi')).toBe('https://x.com/?q=hi');
});
it('replaces every occurrence', () => {
expect(substituteUrlTemplate('a/%WORD%/b/%WORD%', 'X')).toBe('a/X/b/X');
});
});
describe('isValidUrlTemplate', () => {
it('requires http(s):// + a placeholder', () => {
expect(isValidUrlTemplate('https://x.com/?q=%WORD%')).toBe(true);
expect(isValidUrlTemplate(' http://x.com/?q=%WORD% ')).toBe(true);
expect(isValidUrlTemplate('https://x.com/?q=%25WORD%25')).toBe(true);
});
it('rejects missing scheme or placeholder', () => {
expect(isValidUrlTemplate('https://x.com/?q=hi')).toBe(false);
expect(isValidUrlTemplate('//x.com/?q=%WORD%')).toBe(false);
expect(isValidUrlTemplate('ftp://x.com/?q=%WORD%')).toBe(false);
expect(isValidUrlTemplate('')).toBe(false);
});
});
describe('getBuiltinWebSearch', () => {
it('returns each of the three built-ins by id', () => {
for (const tpl of BUILTIN_WEB_SEARCHES) {
const found = getBuiltinWebSearch(tpl.id);
expect(found).toBeDefined();
expect(found?.urlTemplate).toContain('%WORD%');
}
});
it('returns undefined for unknown ids', () => {
expect(getBuiltinWebSearch('web:builtin:nope')).toBeUndefined();
expect(getBuiltinWebSearch('web:custom:abc')).toBeUndefined();
});
});
describe('webSearchProvider', () => {
const customEntry: WebSearchEntry = {
id: 'web:abc123',
name: 'Custom Site',
urlTemplate: 'https://example.com/?q=%WORD%',
};
it('renders headword + sub-label + open button + URL preview', async () => {
const provider = createWebSearchProvider({ template: customEntry });
const container = document.createElement('div');
const outcome = await provider.lookup('hello world', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
if (outcome.ok) {
expect(outcome.headword).toBe('hello world');
expect(outcome.sourceLabel).toBe('Custom Site');
}
expect(container.querySelector('h1')?.textContent).toBe('hello world');
const link = container.querySelector('a');
expect(link).toBeTruthy();
expect(link?.getAttribute('href')).toBe('https://example.com/?q=hello%20world');
expect(link?.getAttribute('rel')).toBe('noopener noreferrer');
// URL preview rendered for transparency.
expect(container.textContent).toContain('https://example.com/?q=hello%20world');
});
it('returns empty for a blank word', async () => {
const provider = createWebSearchProvider({ template: customEntry });
const outcome = await provider.lookup(' ', {
signal: new AbortController().signal,
container: document.createElement('div'),
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('empty');
});
it('respects abort signals', async () => {
const provider = createWebSearchProvider({ template: customEntry });
const ac = new AbortController();
ac.abort();
const outcome = await provider.lookup('hello', {
signal: ac.signal,
container: document.createElement('div'),
});
expect(outcome.ok).toBe(false);
if (!outcome.ok) expect(outcome.reason).toBe('error');
});
it('uses a label override when provided', () => {
const provider = createWebSearchProvider({
template: customEntry,
label: 'Override',
});
expect(provider.label).toBe('Override');
expect(provider.kind).toBe('web');
});
it('opens each built-in URL with the headword substituted', async () => {
for (const tpl of BUILTIN_WEB_SEARCHES) {
const provider = createWebSearchProvider({ template: tpl });
const container = document.createElement('div');
await provider.lookup('hello', {
signal: new AbortController().signal,
container,
});
const link = container.querySelector('a');
expect(link).toBeTruthy();
const href = link?.getAttribute('href') ?? '';
expect(href).toContain('hello');
expect(href.startsWith('https://')).toBe(true);
expect(href).not.toContain('%WORD%');
}
});
});
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types';
const ZERO = (s: string) => s.startsWith('web:builtin:');
describe('customDictionaryStore — web search CRUD', () => {
beforeEach(() => {
// Reset state to defaults so tests don't bleed.
useCustomDictionaryStore.setState({
dictionaries: [],
settings: {
providerOrder: [
BUILTIN_WEB_SEARCH_IDS.google,
BUILTIN_WEB_SEARCH_IDS.urban,
BUILTIN_WEB_SEARCH_IDS.merriamWebster,
],
providerEnabled: {
[BUILTIN_WEB_SEARCH_IDS.google]: false,
[BUILTIN_WEB_SEARCH_IDS.urban]: false,
[BUILTIN_WEB_SEARCH_IDS.merriamWebster]: false,
},
webSearches: [],
},
});
});
it('seeds the three built-in web ids in default order, all disabled', () => {
const { settings } = useCustomDictionaryStore.getState();
const builtinWeb = settings.providerOrder.filter(ZERO);
expect(builtinWeb).toEqual([
BUILTIN_WEB_SEARCH_IDS.google,
BUILTIN_WEB_SEARCH_IDS.urban,
BUILTIN_WEB_SEARCH_IDS.merriamWebster,
]);
for (const id of builtinWeb) {
expect(settings.providerEnabled[id]).toBe(false);
}
});
it('addWebSearch appends to order, enables, returns the entry', () => {
const { addWebSearch } = useCustomDictionaryStore.getState();
const entry = addWebSearch('My Site', 'https://example.com/?q=%WORD%');
expect(entry.id.startsWith('web:')).toBe(true);
expect(entry.id.startsWith('web:builtin:')).toBe(false);
expect(entry.name).toBe('My Site');
expect(entry.urlTemplate).toBe('https://example.com/?q=%WORD%');
const after = useCustomDictionaryStore.getState().settings;
expect(after.providerOrder.includes(entry.id)).toBe(true);
expect(after.providerEnabled[entry.id]).toBe(true);
expect((after.webSearches ?? []).map((w) => w.id)).toEqual([entry.id]);
});
it('addWebSearch trims whitespace from name and URL', () => {
const { addWebSearch } = useCustomDictionaryStore.getState();
const entry = addWebSearch(' Spaced Name ', ' https://x.com/?q=%WORD% ');
expect(entry.name).toBe('Spaced Name');
expect(entry.urlTemplate).toBe('https://x.com/?q=%WORD%');
});
it('updateWebSearch updates name + URL of a custom entry', () => {
const { addWebSearch, updateWebSearch } = useCustomDictionaryStore.getState();
const entry = addWebSearch('Old', 'https://old.com/?q=%WORD%');
updateWebSearch(entry.id, { name: 'New', urlTemplate: 'https://new.com/?q=%WORD%' });
const list = useCustomDictionaryStore.getState().settings.webSearches ?? [];
const updated = list.find((w) => w.id === entry.id);
expect(updated?.name).toBe('New');
expect(updated?.urlTemplate).toBe('https://new.com/?q=%WORD%');
});
it('updateWebSearch is a no-op for built-in ids', () => {
const { updateWebSearch, settings } = useCustomDictionaryStore.getState();
updateWebSearch(BUILTIN_WEB_SEARCH_IDS.google, { name: 'Hijacked' });
// No `webSearches` entry was added or modified.
const after = useCustomDictionaryStore.getState().settings;
expect(after.webSearches).toEqual(settings.webSearches ?? []);
});
it('removeWebSearch soft-deletes a custom entry and removes it from order/enabled', () => {
const { addWebSearch, removeWebSearch } = useCustomDictionaryStore.getState();
const entry = addWebSearch('Tmp', 'https://tmp.com/?q=%WORD%');
expect(removeWebSearch(entry.id)).toBe(true);
const after = useCustomDictionaryStore.getState().settings;
expect(after.providerOrder.includes(entry.id)).toBe(false);
expect(entry.id in after.providerEnabled).toBe(false);
const found = (after.webSearches ?? []).find((w) => w.id === entry.id);
expect(found?.deletedAt).toBeGreaterThan(0);
});
it('removeWebSearch refuses built-in ids', () => {
const { removeWebSearch } = useCustomDictionaryStore.getState();
expect(removeWebSearch(BUILTIN_WEB_SEARCH_IDS.google)).toBe(false);
const after = useCustomDictionaryStore.getState().settings;
expect(after.providerOrder.includes(BUILTIN_WEB_SEARCH_IDS.google)).toBe(true);
});
});
@@ -51,7 +51,8 @@ import ExportMarkdownDialog from './ExportMarkdownDialog';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { settings, setSettingsDialogBookKey, setSettingsDialogOpen, setActiveSettingsItemId } =
useSettingsStore();
const { isDarkMode } = useThemeStore();
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
@@ -970,6 +971,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
popupWidth={dictPopupWidth}
popupHeight={dictPopupHeight}
onDismiss={handleDismissPopupAndSelection}
onManage={() => {
// Dismiss the popup so the user returns to the reader cleanly
// when they close settings; the dictionaries sub-page in the
// SettingsDialog is enough surface for managing providers.
handleDismissPopupAndSelection();
setSettingsDialogBookKey(bookKey);
setActiveSettingsItemId('settings.language.dictionaries.manage');
setSettingsDialogOpen(true);
}}
/>
)}
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MdArrowBack } from 'react-icons/md';
import { MdArrowBack, MdSettings } from 'react-icons/md';
import clsx from 'clsx';
import { openUrl } from '@tauri-apps/plugin-opener';
@@ -22,6 +22,13 @@ interface DictionaryPopupProps {
popupWidth: number;
popupHeight: number;
onDismiss?: () => void;
/**
* Invoked when the user clicks the bottom-right "Manage Dictionaries"
* icon. The host (Annotator) decides how to navigate typically by
* opening the SettingsDialog and deep-linking to the dictionaries
* sub-page.
*/
onManage?: () => void;
}
interface TabState {
@@ -45,6 +52,7 @@ const DictionaryPopup: React.FC<DictionaryPopupProps> = ({
popupWidth,
popupHeight,
onDismiss,
onManage,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
@@ -294,7 +302,11 @@ const DictionaryPopup: React.FC<DictionaryPopupProps> = ({
className='select-text'
onDismiss={onDismiss}
>
<div className='relative flex h-full flex-col'>
{/* `overflow-hidden rounded-lg` clips child surfaces (the tab strip's
gray bg, the bottom footer divider, etc.) to the popup's rounded
shape without it the tab bar's `bg-base-300/40` and `border-b`
paint into the corner area and break the rounded edge. */}
<div className='relative flex h-full flex-col overflow-hidden rounded-lg'>
{providers.length > 1 && (
<div
role='tablist'
@@ -386,14 +398,33 @@ const DictionaryPopup: React.FC<DictionaryPopupProps> = ({
})
)}
{sourceLabel && (
<footer
className='not-eink:opacity-60 mt-auto flex items-center px-4 py-2 text-sm'
title={`Source: ${sourceLabel}`}
>
{/* min-w-0 lets the truncate engage inside the flex parent
without it the span sizes to its content and overflows. */}
<span className='min-w-0 flex-1 truncate'>Source: {sourceLabel}</span>
{/* Footer always renders so the manage-dictionaries icon has a
consistent home at the bottom-right of every tab. The source
label fills the left side when present; otherwise the spacer
pushes the icon to the right. */}
{(sourceLabel || onManage) && (
<footer className='mt-auto flex shrink-0 items-center gap-2 px-3 py-1.5'>
{sourceLabel ? (
<span
className='not-eink:opacity-60 min-w-0 flex-1 truncate text-sm'
title={`Source: ${sourceLabel}`}
>
Source: {sourceLabel}
</span>
) : (
<span className='flex-1' />
)}
{onManage && (
<button
type='button'
onClick={onManage}
aria-label={_('Manage Dictionaries')}
title={_('Manage Dictionaries')}
className='btn btn-ghost btn-square btn-xs text-base-content/60 hover:text-base-content not-eink:hover:bg-base-200/60 shrink-0'
>
<MdSettings size={16} />
</button>
)}
</footer>
)}
</div>
@@ -27,7 +27,11 @@ import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { eventDispatcher } from '@/utils/event';
import { evictProvider } from '@/services/dictionaries/registry';
import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { ImportedDictionary, WebSearchEntry } from '@/services/dictionaries/types';
import {
getBuiltinWebSearch,
isValidUrlTemplate,
} from '@/services/dictionaries/webSearchTemplates';
interface CustomDictionariesProps {
onBack: () => void;
@@ -36,13 +40,23 @@ interface CustomDictionariesProps {
interface ProviderRow {
id: string;
label: string;
kind: 'builtin' | 'stardict' | 'mdict';
kind: 'builtin' | 'stardict' | 'mdict' | 'dict' | 'slob' | 'web';
badge: string;
imported?: ImportedDictionary;
/** Set on `kind: 'web'` rows. The shape distinguishes deletable custom
* entries (when `builtinWeb` is false) from immutable built-ins. */
webSearch?: WebSearchEntry;
builtinWeb?: boolean;
disabled?: boolean;
reason?: string;
}
const builtinWebLabel = (id: string, _: (key: string) => string): string => {
const tpl = getBuiltinWebSearch(id);
if (!tpl) return id;
return _(tpl.nameKey);
};
const builtinLabel = (id: string, _: (key: string) => string): string => {
if (id === BUILTIN_PROVIDER_IDS.wiktionary) return _('Wiktionary');
if (id === BUILTIN_PROVIDER_IDS.wikipedia) return _('Wikipedia');
@@ -55,6 +69,7 @@ interface SortableRowProps {
isDeleteMode: boolean;
onToggle: (id: string, next: boolean) => void;
onDelete: (row: ProviderRow) => void;
onEditWebSearch?: (entry: WebSearchEntry) => void;
_: (key: string, options?: Record<string, number | string>) => string;
}
@@ -64,6 +79,7 @@ const SortableRow: React.FC<SortableRowProps> = ({
isDeleteMode,
onToggle,
onDelete,
onEditWebSearch,
_,
}) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -83,8 +99,8 @@ const SortableRow: React.FC<SortableRowProps> = ({
ref={setNodeRef}
style={style}
className={clsx(
'flex items-center gap-2 px-3 py-2',
isDragging && 'bg-base-200 z-10 shadow-md',
'flex items-center gap-2 px-3 py-2 transition-colors',
isDragging ? 'bg-base-200 z-10 shadow-md' : 'hover:bg-base-200/40',
)}
>
{/* Drag handle (left). Always present so reorder works for built-ins
@@ -110,7 +126,15 @@ const SortableRow: React.FC<SortableRowProps> = ({
>
{row.label}
</span>
<span className='badge badge-sm badge-ghost shrink-0'>{row.badge}</span>
{row.kind === 'web' && !row.builtinWeb && row.webSearch && onEditWebSearch && (
<button
type='button'
onClick={() => onEditWebSearch(row.webSearch!)}
className='link link-hover text-base-content/60 shrink-0 text-xs'
>
{_('Edit')}
</button>
)}
</div>
{row.reason && (
<div className='text-warning mt-1 flex items-start gap-1 text-xs'>
@@ -120,6 +144,11 @@ const SortableRow: React.FC<SortableRowProps> = ({
)}
</div>
{/* End-aligned type badge. Sits just before the toggle so all
badges form a uniform column regardless of name length, instead
of trailing the truncated name at a ragged x position. */}
<span className='badge badge-sm badge-ghost shrink-0'>{row.badge}</span>
<input
type='checkbox'
className='toggle toggle-sm shrink-0'
@@ -129,10 +158,11 @@ const SortableRow: React.FC<SortableRowProps> = ({
aria-label={enabled ? _('Disable') : _('Enable')}
/>
{/* Delete X only for imported rows, only in delete mode. Built-ins
never show it; imported rows reserve no width when not in delete
mode so their toggle aligns with the built-ins'. */}
{row.imported && isDeleteMode && (
{/* Delete X for imported dictionaries and custom web searches, only
in delete mode. Built-ins (incl. built-in web searches) never show
it; deletable rows reserve no width when not in delete mode so the
toggles align across the list. */}
{(row.imported || (row.kind === 'web' && !row.builtinWeb)) && isDeleteMode && (
<button
type='button'
onClick={() => onDelete(row)}
@@ -157,6 +187,9 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
removeDictionary,
reorder,
setEnabled,
addWebSearch,
updateWebSearch,
removeWebSearch,
saveCustomDictionaries,
loadCustomDictionaries,
} = useCustomDictionaryStore();
@@ -170,8 +203,43 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
const [importing, setImporting] = useState(false);
const [isDeleteMode, setIsDeleteMode] = useState(false);
// Add/edit web-search modal state. `editingId` is `null` for "add", a
// custom entry's id for "edit".
const [webModal, setWebModal] = useState<null | {
editingId: string | null;
name: string;
urlTemplate: string;
}>(null);
const openAddWebSearch = () => setWebModal({ editingId: null, name: '', urlTemplate: '' });
const openEditWebSearch = (entry: WebSearchEntry) =>
setWebModal({ editingId: entry.id, name: entry.name, urlTemplate: entry.urlTemplate });
const closeWebModal = () => setWebModal(null);
const submitWebModal = async () => {
if (!webModal) return;
const name = webModal.name.trim();
const url = webModal.urlTemplate.trim();
if (!name || !isValidUrlTemplate(url)) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('URL template must start with http(s):// and contain %WORD%.'),
timeout: 4000,
});
return;
}
if (webModal.editingId) {
updateWebSearch(webModal.editingId, { name, urlTemplate: url });
// Re-create the cached provider so the new template + label take effect.
evictProvider(webModal.editingId);
} else {
addWebSearch(name, url);
}
await saveCustomDictionaries(envConfig);
setWebModal(null);
};
const buildRows = (): ProviderRow[] => {
const dictById = new Map(dictionaries.map((d) => [d.id, d]));
const webById = new Map((settings.webSearches ?? []).map((w) => [w.id, w]));
const rows: ProviderRow[] = [];
for (const id of settings.providerOrder) {
if (id.startsWith('builtin:')) {
@@ -183,6 +251,32 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
});
continue;
}
if (id.startsWith('web:builtin:')) {
const tpl = getBuiltinWebSearch(id);
if (!tpl) continue;
rows.push({
id,
label: builtinWebLabel(id, _),
kind: 'web',
badge: _('Web'),
webSearch: tpl,
builtinWeb: true,
});
continue;
}
if (id.startsWith('web:')) {
const w = webById.get(id);
if (!w || w.deletedAt) continue;
rows.push({
id,
label: w.name,
kind: 'web',
badge: _('Web'),
webSearch: w,
builtinWeb: false,
});
continue;
}
const dict = dictById.get(id);
if (!dict || dict.deletedAt) continue;
let reason: string | undefined;
@@ -198,7 +292,14 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
id,
label: dict.name,
kind: dict.kind,
badge: dict.kind === 'mdict' ? _('MDict') : _('StarDict'),
badge:
dict.kind === 'mdict'
? _('MDict')
: dict.kind === 'dict'
? _('DICT')
: dict.kind === 'slob'
? _('Slob')
: _('StarDict'),
imported: dict,
disabled,
reason,
@@ -208,7 +309,7 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
};
const rows = buildRows();
const hasImported = rows.some((r) => r.imported);
const hasDeletable = rows.some((r) => r.imported || (r.kind === 'web' && !r.builtinWeb));
// dnd-kit sensors. PointerSensor with a small distance gate avoids
// hijacking simple clicks on the drag handle. TouchSensor with a delay
@@ -264,21 +365,28 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
};
const handleDelete = async (row: ProviderRow) => {
if (!row.imported) return;
const dict = row.imported;
try {
await appService?.deleteDictionary(dict);
} catch (err) {
console.warn('Failed to delete dictionary files:', err);
if (row.imported) {
const dict = row.imported;
try {
await appService?.deleteDictionary(dict);
} catch (err) {
console.warn('Failed to delete dictionary files:', err);
}
removeDictionary(dict.id);
evictProvider(dict.id);
} else if (row.kind === 'web' && !row.builtinWeb && row.webSearch) {
removeWebSearch(row.webSearch.id);
evictProvider(row.id);
} else {
return;
}
removeDictionary(dict.id);
evictProvider(dict.id);
await saveCustomDictionaries(envConfig);
// Auto-leave delete mode when the last imported entry is gone — there's
// Auto-leave delete mode when the last deletable entry is gone — there's
// nothing left to delete.
if (rows.filter((r) => r.imported).length <= 1) {
setIsDeleteMode(false);
}
const remaining = rows.filter(
(r) => r.id !== row.id && (r.imported || (r.kind === 'web' && !r.builtinWeb)),
);
if (remaining.length === 0) setIsDeleteMode(false);
};
const handleToggle = async (id: string, next: boolean) => {
@@ -313,7 +421,7 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
<li className='font-medium'>{_('Dictionaries')}</li>
</ul>
</div>
{hasImported && (
{hasDeletable && (
<button
onClick={() => setIsDeleteMode((v) => !v)}
className='btn btn-ghost btn-sm text-base-content gap-2'
@@ -331,23 +439,33 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
)}
</div>
<div className='card border-primary/50 hover:border-primary/75 group mb-4 border-2 transition-colors'>
{/* Primary actions. Flat outline-primary buttons so they still read
as the page's primary CTAs but consume far less vertical space
than the previous bordered cards. On narrow screens (mobile) they
stack; on tablet+ they sit side-by-side. */}
<div className='mb-4 grid grid-cols-1 gap-2 sm:grid-cols-2'>
<button
type='button'
className='card-body flex cursor-pointer items-center justify-center p-3 text-center'
onClick={handleImport}
disabled={importing}
className='btn btn-outline btn-primary gap-2 normal-case'
>
<div className='flex items-center gap-2'>
<MdAdd className='text-primary/85 group-hover:text-primary h-6 w-6' />
<div className='text-primary/85 group-hover:text-primary line-clamp-1 font-medium'>
{importing ? _('Importing…') : _('Import Dictionary')}
</div>
</div>
<MdAdd className='h-5 w-5' />
<span className='line-clamp-1'>
{importing ? _('Importing…') : _('Import Dictionary')}
</span>
</button>
<button
type='button'
onClick={openAddWebSearch}
className='btn btn-outline btn-primary gap-2 normal-case'
>
<MdAdd className='h-5 w-5' />
<span className='line-clamp-1'>{_('Add Web Search')}</span>
</button>
</div>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='card border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
{rows.length === 0 && (
<div className='text-base-content/60 px-4 py-6 text-center text-sm'>
@@ -368,6 +486,7 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
isDeleteMode={isDeleteMode}
onToggle={handleToggle}
onDelete={handleDelete}
onEditWebSearch={openEditWebSearch}
_={_}
/>
))}
@@ -376,17 +495,76 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
</div>
</div>
<div className='bg-base-200/30 my-6 rounded-lg p-4'>
<div className='text-base-content/70 text-sm sm:text-xs'>
<div className='mb-1 indent-2 font-medium'>{_('Tips')}:</div>
<ul className='list-outside list-disc space-y-1 ps-2'>
<li>{_('Drag the handle on the left to reorder.')}</li>
<div className='bg-base-200/40 mt-4 rounded-lg p-3'>
<div className='text-base-content/70 text-xs'>
<div className='mb-1.5 flex items-center gap-1.5 font-medium'>
<MdInfoOutline className='h-3.5 w-3.5' />
{_('Tips')}
</div>
<ul className='list-outside list-disc space-y-0.5 ps-4'>
<li>{_('StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).')}</li>
<li>{_('MDict bundles use .mdx files; companion .mdd files are optional.')}</li>
<li>{_('DICT bundles need a .index file and a .dict.dz file.')}</li>
<li>{_('Slob bundles need a .slob file.')}</li>
<li>{_('Select all the bundle files together when importing.')}</li>
</ul>
</div>
</div>
{/* Add / edit web-search modal. Lightweight inline `<dialog>` (daisyUI
modal classes); the heavier `Dialog.tsx` is overkill for a 2-field
form. */}
{webModal && (
<div className='modal modal-open' role='dialog'>
<div className='modal-box w-11/12 max-w-md'>
<h3 className='text-base font-semibold'>
{webModal.editingId ? _('Edit Web Search') : _('Add Web Search')}
</h3>
<div className='mt-4 space-y-3'>
<label className='form-control w-full'>
<span className='label-text text-sm'>{_('Name')}</span>
<input
type='text'
className='input input-bordered input-sm w-full'
value={webModal.name}
placeholder={_('e.g. Google')}
onChange={(e) => setWebModal((m) => (m ? { ...m, name: e.target.value } : m))}
/>
</label>
<label className='form-control w-full'>
<span className='label-text text-sm'>{_('URL Template')}</span>
<input
type='url'
className='input input-bordered input-sm w-full'
value={webModal.urlTemplate}
placeholder='https://www.google.com/search?q=%WORD%'
onChange={(e) =>
setWebModal((m) => (m ? { ...m, urlTemplate: e.target.value } : m))
}
/>
<span className='label-text-alt text-base-content/60 mt-1 text-xs'>
{_('Use %WORD% where the looked-up word should appear.')}
</span>
</label>
</div>
<div className='modal-action'>
<button type='button' onClick={closeWebModal} className='btn btn-ghost btn-sm'>
{_('Cancel')}
</button>
<button type='button' onClick={submitWebModal} className='btn btn-primary btn-sm'>
{_('Save')}
</button>
</div>
</div>
{/* Click-outside dismiss. */}
<button
type='button'
aria-label={_('Close')}
className='modal-backdrop'
onClick={closeWebModal}
/>
</div>
)}
</div>
);
};
@@ -25,7 +25,8 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const _ = useTranslation();
const { token } = useAuth();
const { envConfig } = useEnv();
const { settings, applyUILanguage } = useSettingsStore();
const { settings, applyUILanguage, activeSettingsItemId, setActiveSettingsItemId } =
useSettingsStore();
const { getView, getViewSettings, setViewSettings, recreateViewer } = useReaderStore();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
@@ -44,6 +45,18 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
);
const [showCustomDictionaries, setShowCustomDictionaries] = useState(false);
// Deep-link: callers (e.g. the dictionary popup's manage icon) can set
// activeSettingsItemId to `'settings.language.dictionaries.manage'` to
// jump straight into the Manage Dictionaries sub-page on open. Clear the
// id once consumed so SettingsDialog's scroll-to-element fallback
// (which runs on a 100ms timeout) doesn't re-fire.
useEffect(() => {
if (activeSettingsItemId === 'settings.language.dictionaries.manage') {
setShowCustomDictionaries(true);
setActiveSettingsItemId(null);
}
}, [activeSettingsItemId, setActiveSettingsItemId]);
const resetToDefaults = useResetViewSettings();
const handleReset = () => {
@@ -140,8 +140,8 @@ export const FILE_SELECTION_PRESETS = {
dialogTitle: _('Select Fonts'),
},
dictionaries: {
accept: '.mdx, .mdd, .ifo, .idx, .dict, .dz, .syn',
extensions: ['mdx', 'mdd', 'ifo', 'idx', 'dict', 'dz', 'syn'],
accept: '.mdx, .mdd, .ifo, .idx, .dict, .dz, .syn, .index, .slob',
extensions: ['mdx', 'mdd', 'ifo', 'idx', 'dict', 'dz', 'syn', 'index', 'slob'],
dialogTitle: _('Select Dictionary Files'),
},
covers: {
@@ -0,0 +1,261 @@
/**
* DICT (dictd) reader. Reference: RFC 2229 § 3.1 (dictzip body), the
* `dictd` source `dictd.h`/`index.c` for the `.index` grammar.
*
* A DICT bundle is two files sharing a stem:
* - `name.index` : plain ASCII, one entry per line:
* `headword\tbase64(offset)\tbase64(length)`.
* The offsets are big-endian 6-bit groups encoded with
* the alphabet `[A-Za-z0-9+/]` *not* standard base64
* (no padding, variable-length).
* - `name.dict` : raw or `.dict.dz` dictzip-compressed body. Random-
* access reads at the offsets from the index.
*
* Index entries with words starting `00database` are reserved metadata:
*
* 00databasealphabet : 8.dictsort alphabet
* 00databasedictfmt1121 : format marker
* 00databaseinfo : long-form description
* 00databaseshort : one-line label
* 00databaseurl : source URL
* 00databaseutf8 : present iff bodies are UTF-8
*
* The reader exposes:
* - `info`: parsed metadata including the human-readable label.
* - `entryCount`: number of non-metadata entries.
* - `lookup(word)`: case-insensitive exact match `Uint8Array` body.
* - `read(entry)`: random-access read of the dict body.
*
* Like `StarDictReader`, it does index parsing once at init, then per-
* lookup it does a binary search over an `Int32Array` of line offsets and
* reads ~one line plus one chunk from the dict body.
*/
import { LRU, loadDictBody, type DictBody } from './dictZip';
const decoder = new TextDecoder('utf-8');
export interface DictEntry {
word: string;
offset: number;
size: number;
}
/** RFC 2229 base64-like alphabet (A-Za-z0-9+/), big-endian 6-bit groups. */
const B64: Int8Array = (() => {
const t = new Int8Array(256).fill(-1);
const set = (s: string, base: number) => {
for (let i = 0; i < s.length; i++) t[s.charCodeAt(i)] = base + i;
};
set('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 0);
set('abcdefghijklmnopqrstuvwxyz', 26);
set('0123456789', 52);
t['+'.charCodeAt(0)] = 62;
t['/'.charCodeAt(0)] = 63;
return t;
})();
/** Decode a DICT base64 token (no padding, variable length) into a number. */
export function decodeDictBase64(s: string): number {
let n = 0;
for (let i = 0; i < s.length; i++) {
const v = B64[s.charCodeAt(i)]!;
if (v < 0) throw new Error(`Invalid DICT base64 character at ${i}: ${s[i]}`);
n = n * 64 + v;
}
return n;
}
export interface DictInfo {
/** Human-readable label, derived from `00databaseshort` if present. */
label?: string;
/** Long-form description from `00databaseinfo`. */
description?: string;
/** Source URL from `00databaseurl`. */
url?: string;
/** dictd alphabet hint from `00databasealphabet`. Rarely useful. */
alphabet?: string;
/** True iff the dict is UTF-8 (presence of `00databaseutf8`). */
utf8: boolean;
}
const cmpAscii = (a: string, b: string): number => {
const x = a.toLowerCase();
const y = b.toLowerCase();
return x < y ? -1 : x > y ? 1 : 0;
};
/**
* Parse a `.index` text into:
* - `entries`: `Int32Array` of `[offset, size]` pairs (2 ints per entry).
* - `words`: `string[]` aligned with `entries`, headwords lowercased so
* binary search is straightforward.
* - `meta`: a record of `00database*` headwords body slice (offset,size)
* so callers can read out the description / short label after the body
* is open.
*/
export interface ParsedDictIndex {
/** Sorted (case-insensitive) headwords (original case preserved). */
words: string[];
/** Per-entry [offset, size] pairs, two ints per entry. */
entries: Int32Array;
/** `00database*` metadata entries by name, holding their body slice. */
meta: Record<string, { offset: number; size: number }>;
}
export function parseDictIndex(text: string): ParsedDictIndex {
const words: string[] = [];
const offsets: number[] = [];
const sizes: number[] = [];
const meta: Record<string, { offset: number; size: number }> = {};
let line = 0;
let pos = 0;
while (pos < text.length) {
let nl = text.indexOf('\n', pos);
if (nl < 0) nl = text.length;
const ln =
text.charCodeAt(nl - 1) === 13 /* \r */ ? text.slice(pos, nl - 1) : text.slice(pos, nl);
pos = nl + 1;
if (!ln) continue;
line++;
const tab1 = ln.indexOf('\t');
if (tab1 < 0) continue;
const tab2 = ln.indexOf('\t', tab1 + 1);
if (tab2 < 0) continue;
const word = ln.slice(0, tab1);
const offTok = ln.slice(tab1 + 1, tab2);
const sizeTok = ln.slice(tab2 + 1).trim();
let off: number;
let size: number;
try {
off = decodeDictBase64(offTok);
size = decodeDictBase64(sizeTok);
} catch {
continue;
}
if (word.startsWith('00database')) {
meta[word] = { offset: off, size };
continue;
}
words.push(word);
offsets.push(off);
sizes.push(size);
}
// The dictd convention is that .index is sorted by `dictsort` (mostly
// case-insensitive ASCII). We re-sort here defensively so binary search
// is correct regardless of the producer.
const order = words.map((_, i) => i).sort((a, b) => cmpAscii(words[a]!, words[b]!));
const sortedWords: string[] = new Array(words.length);
const sortedEntries = new Int32Array(words.length * 2);
for (let i = 0; i < order.length; i++) {
const k = order[i]!;
sortedWords[i] = words[k]!;
sortedEntries[i * 2] = offsets[k]!;
sortedEntries[i * 2 + 1] = sizes[k]!;
}
void line;
return { words: sortedWords, entries: sortedEntries, meta };
}
export interface DictReaderOpts {
index: Blob;
/** Either `.dict.dz` (gzip) or a raw `.dict` (no compression). */
dict: Blob;
/** LRU cache size for decoded bodies. Defaults to 256. */
cacheSize?: number;
}
export class DictReader {
info: DictInfo = { utf8: false };
private words: string[] = [];
private entries: Int32Array = new Int32Array(0);
private body: DictBody | null = null;
private bodyCache: LRU<number, Uint8Array>;
constructor(cacheSize = 256) {
this.bodyCache = new LRU<number, Uint8Array>(cacheSize);
}
async load(opts: DictReaderOpts): Promise<void> {
if (opts.cacheSize !== undefined) {
this.bodyCache = new LRU<number, Uint8Array>(opts.cacheSize);
}
const cacheSize = opts.cacheSize ?? 256;
const indexText = await opts.index.text();
const parsed = parseDictIndex(indexText);
this.words = parsed.words;
this.entries = parsed.entries;
this.body = await loadDictBody(opts.dict, {
chunkCacheSize: Math.max(8, Math.floor(cacheSize / 16)),
});
// Resolve metadata bodies (if present) against the now-open dict.
const tryRead = async (key: string): Promise<string | undefined> => {
const m = parsed.meta[key];
if (!m || !this.body) return undefined;
const bytes = await this.body.read(m.offset, m.size);
return decoder.decode(bytes).trim();
};
const [short, longInfo, url, alphabet, utf8] = await Promise.all([
tryRead('00databaseshort'),
tryRead('00databaseinfo'),
tryRead('00databaseurl'),
tryRead('00databasealphabet'),
tryRead('00databaseutf8'),
]);
this.info = {
label: short,
description: longInfo,
url,
alphabet,
utf8: utf8 !== undefined,
};
}
get entryCount(): number {
return this.words.length;
}
/** Random-access read of `size` bytes from the dict body at `offset`. */
async read(entry: DictEntry): Promise<Uint8Array> {
if (!this.body) throw new Error('dict body not loaded');
return this.body.read(entry.offset, entry.size);
}
/** Look up a headword (case-insensitive exact match). */
async lookup(word: string): Promise<DictEntry | undefined> {
let lo = 0;
let hi = this.words.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const cmp = cmpAscii(word, this.words[mid]!);
if (cmp === 0) {
return {
word: this.words[mid]!,
offset: this.entries[mid * 2]!,
size: this.entries[mid * 2 + 1]!,
};
}
if (cmp > 0) lo = mid + 1;
else hi = mid - 1;
}
return undefined;
}
/** Decode one entry's body to UTF-8 text, with LRU caching by index. */
async readText(entry: DictEntry, idx?: number): Promise<string> {
if (idx !== undefined) {
const cached = this.bodyCache.get(idx);
if (cached) return decoder.decode(cached);
}
const bytes = await this.read(entry);
if (idx !== undefined) this.bodyCache.set(idx, bytes);
return decoder.decode(bytes);
}
}
@@ -0,0 +1,247 @@
/**
* DictZip reader shared between StarDict (`.dict.dz`) and DICT (`.dict.dz`).
*
* `.dict.dz` is a standard gzip with a FEXTRA "RA" subfield listing the
* compressed length of each chunk. Each chunk's uncompressed length is
* exactly `chlen` (the last may be shorter). Chunks are separated by
* `Z_FULL_FLUSH` (BFINAL=0 + sync marker), so each chunk's deflate stream is
* non-terminating `inflateSync` rejects them with "unexpected EOF".
* fflate's streaming `Inflate.push(bytes, false)` handles them correctly and
* emits chunk bytes via `ondata`.
*
* Both readers share the same lazy chunk decompression strategy. The
* `loadDictBody` factory probes the candidate `.dict.dz`; when chunk-mode is
* viable it returns a `DictZipChunkedDict` that decompresses on demand. When
* the FEXTRA is missing or the probe fails (or the file is plain `.dict`),
* it falls back to whole-file gunzip / passthrough into a buffer.
*/
import { gunzipSync, Inflate } from 'fflate';
const GZIP_MAGIC = [0x1f, 0x8b];
const isGzip = (bytes: Uint8Array): boolean =>
bytes.length >= 2 && bytes[0] === GZIP_MAGIC[0] && bytes[1] === GZIP_MAGIC[1];
export interface DictZipMeta {
/** Uncompressed bytes per chunk (last may be shorter). */
chlen: number;
/** Compressed bytes per chunk; sums to `compressedDataSize`. */
chunkSizes: number[];
/** Byte offset within the file where compressed chunk data begins. */
compressedDataOffset: number;
}
/**
* Parse the gzip header + FEXTRA RA subfield. Returns `null` for non-gzip
* files or gzip files without an RA subfield (those need whole-file gunzip).
*/
export function parseDictZipHeader(bytes: Uint8Array): DictZipMeta | null {
if (bytes.length < 12 || bytes[0] !== 0x1f || bytes[1] !== 0x8b || bytes[2] !== 0x08) {
return null;
}
const flg = bytes[3]!;
const hasFEXTRA = (flg & 0b100) !== 0;
if (!hasFEXTRA) return null;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const xlen = view.getUint16(10, true);
let chlen = 0;
let chcnt = 0;
const chunkSizes: number[] = [];
let p = 12;
while (p + 4 <= 12 + xlen) {
const si1 = bytes[p]!;
const si2 = bytes[p + 1]!;
const slen = view.getUint16(p + 2, true);
if (si1 === 0x52 && si2 === 0x41) {
// RA subfield: ver(2) + chlen(2) + chcnt(2) + chcnt × chunkSize(2).
const ver = view.getUint16(p + 4, true);
if (ver !== 1) return null;
chlen = view.getUint16(p + 6, true);
chcnt = view.getUint16(p + 8, true);
for (let i = 0; i < chcnt; i++) {
chunkSizes.push(view.getUint16(p + 10 + 2 * i, true));
}
}
p += 4 + slen;
}
if (chcnt === 0 || chunkSizes.length !== chcnt) return null;
// Skip past FEXTRA + optional FNAME, FCOMMENT, FHCRC.
let offset = 12 + xlen;
if (flg & 0b1000) {
while (offset < bytes.length && bytes[offset] !== 0) offset++;
offset++;
}
if (flg & 0b10000) {
while (offset < bytes.length && bytes[offset] !== 0) offset++;
offset++;
}
if (flg & 0b10) offset += 2;
return { chlen, chunkSizes, compressedDataOffset: offset };
}
/**
* Inflate a single DictZip chunk via fflate's streaming `Inflate`. The
* chunk's deflate stream ends at `Z_FULL_FLUSH` (BFINAL=0 `inflateSync`
* would reject), so we push with `final=false` and capture the data emitted
* by `ondata`. Returns `null` on failure.
*/
export function inflateChunkStreaming(chunkBytes: Uint8Array): Uint8Array | null {
let result: Uint8Array | null = null;
try {
const inf = new Inflate();
inf.ondata = (data, _final) => {
if (result === null) result = data;
};
inf.push(chunkBytes, false);
} catch {
return null;
}
if (!result || (result as Uint8Array).length === 0) return null;
return result;
}
/**
* Bounded LRU. Tiny enough we don't bother with a real linked-list
* implementation; reusing the Map insertion order is fine.
*/
export class LRU<K, V> {
private readonly max: number;
private readonly map = new Map<K, V>();
constructor(max: number) {
this.max = max;
}
get(key: K): V | undefined {
const v = this.map.get(key);
if (v !== undefined) {
this.map.delete(key);
this.map.set(key, v);
}
return v;
}
set(key: K, value: V): void {
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.max) {
const first = this.map.keys().next().value as K | undefined;
if (first !== undefined) this.map.delete(first);
}
}
}
/** Body of a DICT-style dict file — supports random-access uncompressed reads. */
export interface DictBody {
read(offset: number, size: number): Promise<Uint8Array>;
}
class BufferedDictBody implements DictBody {
constructor(private readonly buf: Uint8Array) {}
async read(offset: number, size: number): Promise<Uint8Array> {
return this.buf.subarray(offset, offset + size);
}
}
class DictZipChunkedDict implements DictBody {
private readonly chunkOffsets: number[];
private readonly chunkCache: LRU<number, Uint8Array>;
constructor(
private readonly blob: Blob,
private readonly meta: DictZipMeta,
cacheSize: number,
) {
this.chunkCache = new LRU<number, Uint8Array>(cacheSize);
const offsets: number[] = [];
let acc = meta.compressedDataOffset;
for (const cs of meta.chunkSizes) {
offsets.push(acc);
acc += cs;
}
this.chunkOffsets = offsets;
}
async read(offset: number, size: number): Promise<Uint8Array> {
const chlen = this.meta.chlen;
const startChunk = Math.floor(offset / chlen);
const endChunk = Math.floor((offset + size - 1) / chlen);
if (startChunk === endChunk) {
const chunk = await this.getChunk(startChunk);
const local = offset - startChunk * chlen;
return chunk.subarray(local, local + size);
}
const parts: Uint8Array[] = [];
let totalLen = 0;
for (let i = startChunk; i <= endChunk; i++) {
const chunk = await this.getChunk(i);
parts.push(chunk);
totalLen += chunk.length;
}
const combined = new Uint8Array(totalLen);
let pos = 0;
for (const p of parts) {
combined.set(p, pos);
pos += p.length;
}
const local = offset - startChunk * chlen;
return combined.subarray(local, local + size);
}
private async getChunk(i: number): Promise<Uint8Array> {
const cached = this.chunkCache.get(i);
if (cached) return cached;
const start = this.chunkOffsets[i]!;
const compressedSize = this.meta.chunkSizes[i]!;
const compressed = new Uint8Array(
await this.blob.slice(start, start + compressedSize).arrayBuffer(),
);
const inflated = inflateChunkStreaming(compressed);
if (!inflated) throw new Error(`Failed to inflate DictZip chunk ${i}`);
this.chunkCache.set(i, inflated);
return inflated;
}
}
async function probeChunkInflate(blob: Blob, meta: DictZipMeta): Promise<boolean> {
if (meta.chunkSizes.length === 0) return false;
const cs = meta.chunkSizes[0]!;
const start = meta.compressedDataOffset;
const compressed = new Uint8Array(await blob.slice(start, start + cs).arrayBuffer());
const inflated = inflateChunkStreaming(compressed);
return !!inflated && inflated.length > 0 && inflated.length <= meta.chlen;
}
export interface LoadDictBodyOpts {
/** LRU size for decompressed chunks in chunk mode. Defaults to 16. */
chunkCacheSize?: number;
}
/**
* Open a `.dict[.dz]` body for random-access reads. Tries lazy DictZip chunk
* mode first (header probe + chunk-0 inflate); falls back to whole-file
* gunzip when the FEXTRA is missing or the probe fails. For raw `.dict`,
* returns a passthrough buffer.
*/
export async function loadDictBody(blob: Blob, opts: LoadDictBodyOpts = {}): Promise<DictBody> {
const cacheSize = opts.chunkCacheSize ?? 16;
// Read just the gzip header + FEXTRA region (much less than the whole
// file). 64 KB is enormously generous — even thousands of chunks fit
// in a few KB.
const headBuf = await blob.slice(0, Math.min(blob.size, 64 * 1024)).arrayBuffer();
const head = new Uint8Array(headBuf);
if (isGzip(head)) {
const meta = parseDictZipHeader(head);
if (meta && (await probeChunkInflate(blob, meta))) {
return new DictZipChunkedDict(blob, meta, cacheSize);
}
const all = new Uint8Array(await blob.arrayBuffer());
return new BufferedDictBody(gunzipSync(all));
}
return new BufferedDictBody(new Uint8Array(await blob.arrayBuffer()));
}
@@ -48,7 +48,20 @@ interface MDictGroup {
mdd: SourceFile[];
}
type Bundle = StarDictGroup | MDictGroup;
interface DictGroup {
kind: 'dict';
stem: string;
index: SourceFile;
dict: SourceFile;
}
interface SlobGroup {
kind: 'slob';
stem: string;
slob: SourceFile;
}
type Bundle = StarDictGroup | MDictGroup | DictGroup | SlobGroup;
interface GroupResult {
bundles: Bundle[];
@@ -96,7 +109,11 @@ function classify(source: SelectedFile): SourceFile {
* - StarDict bundle = exactly one `.ifo` + one `.idx` + one `.dict` or
* `.dict.dz` (sharing a stem). `.syn` is optional.
* - MDict bundle = one `.mdx` + zero or more `.mdd` (sharing a stem).
* - A stem with both StarDict markers AND `.mdx` is treated as two bundles.
* - DICT (dictd) bundle = one `.index` + one `.dict` or `.dict.dz`
* (sharing a stem). Note: `.idx` (StarDict) and `.index` (DICT) differ
* only by spelling the StarDict branch wins when both are present.
* - Slob bundle = one `.slob` file.
* - A stem with multiple format markers is treated as multiple bundles.
*/
export function groupBundlesByStem(files: SelectedFile[]): GroupResult {
const classified = files.map(classify);
@@ -111,15 +128,21 @@ export function groupBundlesByStem(files: SelectedFile[]): GroupResult {
for (const [stem, group] of byStem) {
const ifo = group.find((f) => f.ext === 'ifo');
const idx = group.find((f) => f.ext === 'idx');
const indexFile = group.find((f) => f.ext === 'index');
const dict = group.find((f) => f.ext === 'dict' || f.isDictZip);
const syn = group.find((f) => f.ext === 'syn');
const mdx = group.find((f) => f.ext === 'mdx');
const mdd = group.filter((f) => f.ext === 'mdd');
const slob = group.find((f) => f.ext === 'slob');
if (ifo && idx && dict) {
bundles.push({ kind: 'stardict', stem, ifo, idx, dict, syn });
} else if (indexFile && dict) {
bundles.push({ kind: 'dict', stem, index: indexFile, dict });
} else if (mdx) {
bundles.push({ kind: 'mdict', stem, mdx, mdd });
} else if (slob) {
bundles.push({ kind: 'slob', stem, slob });
} else {
orphans.push(...group);
}
@@ -313,6 +336,114 @@ async function importMdictBundle(fs: FileSystem, group: MDictGroup): Promise<Imp
};
}
async function importDictBundle(fs: FileSystem, group: DictGroup): Promise<ImportedDictionary> {
const bundleDir = await createBundleDir(fs);
const indexFile = await readSource(fs, group.index.source);
const dictFile = await readSource(fs, group.dict.source);
await writeBundleFile(fs, bundleDir, group.index.name, indexFile);
await writeBundleFile(fs, bundleDir, group.dict.name, dictFile);
// Try to read the `00databaseshort` body for a friendly bundle name. The
// 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.
const m = indexText.match(/^00databaseshort\t([^\t]+)\t([^\t\r\n]+)/m);
if (m) {
const decode = (s: string): number => {
let n = 0;
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
for (const ch of s) {
const v = A.indexOf(ch);
if (v < 0) throw new Error('bad b64');
n = n * 64 + v;
}
return n;
};
const off = decode(m[1]!);
const size = decode(m[2]!);
// Read the dict body. If gzipped we need the whole thing — but for
// the friendly-name read, that's still cheap (the freedict bundles
// are <300 KB compressed).
const buf = await dictFile.arrayBuffer();
const u8 = new Uint8Array(buf);
let body: Uint8Array;
if (u8[0] === 0x1f && u8[1] === 0x8b) {
const { gunzipSync } = await import('fflate');
body = gunzipSync(u8);
} else {
body = u8;
}
name = new TextDecoder('utf-8').decode(body.subarray(off, off + size)).trim() || group.stem;
}
} 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.';
}
return {
id: bundleDir,
kind: 'dict',
name,
bundleDir,
files: {
index: group.index.name,
dict: group.dict.name,
},
addedAt: Date.now(),
unsupported: unsupported || undefined,
unsupportedReason,
};
}
async function importSlobBundle(fs: FileSystem, group: SlobGroup): Promise<ImportedDictionary> {
const bundleDir = await createBundleDir(fs);
const slobFile = await readSource(fs, group.slob.source);
await writeBundleFile(fs, bundleDir, group.slob.name, slobFile);
// Read header bytes to derive the friendly name + sanity-check compression.
let name = group.stem;
let unsupported = false;
let unsupportedReason: string | undefined;
try {
const { SlobReader } = await import('./slobReader');
const reader = new SlobReader();
await reader.load({ slob: slobFile });
const labelTag = reader.header.tags['label'];
if (labelTag) name = labelTag.replace(/\0+$/u, '') || group.stem;
} catch (err) {
const message = (err as Error).message ?? String(err);
unsupported = true;
if (/Unsupported Slob compression/i.test(message)) {
unsupportedReason = message;
} else if (/Unsupported Slob encoding/i.test(message)) {
unsupportedReason = message;
} else {
unsupportedReason = `Failed to parse Slob header: ${message}`;
}
}
return {
id: bundleDir,
kind: 'slob',
name,
bundleDir,
files: { slob: group.slob.name },
addedAt: Date.now(),
unsupported: unsupported || undefined,
unsupportedReason,
};
}
export interface ImportDictionariesResult {
imported: ImportedDictionary[];
/** Filenames that didn't form a valid bundle. */
@@ -333,8 +464,12 @@ export async function importDictionaries(
for (const bundle of bundles) {
if (bundle.kind === 'stardict') {
imported.push(await importStarDictBundle(fs, bundle));
} else {
} else if (bundle.kind === 'mdict') {
imported.push(await importMdictBundle(fs, bundle));
} else if (bundle.kind === 'dict') {
imported.push(await importDictBundle(fs, bundle));
} else {
imported.push(await importSlobBundle(fs, bundle));
}
}
return { imported, orphanFiles: orphans.map((o) => o.name) };
@@ -0,0 +1,107 @@
/**
* DICT (dictd) provider.
*
* Wraps {@link DictReader} for the popup. Renders entries as plain text
* with newlines preserved DICT bodies are nearly always plain text per
* RFC 2229; HTML is rare and the few HTML-bearing dicts in the wild use
* `MIME` indicators outside the v1 scope.
*/
import type { DictionaryProvider, ImportedDictionary } from '../types';
import type { DictionaryFileOpener } from './starDictProvider';
import { DictReader } from '../dictReader';
const renderEntry = (container: HTMLElement, word: string, text: string): void => {
const h1 = document.createElement('h1');
h1.textContent = word;
h1.className = 'text-lg font-bold';
container.appendChild(h1);
const pre = document.createElement('pre');
pre.textContent = text;
pre.className = 'mt-2 whitespace-pre-wrap break-words text-sm font-sans';
container.appendChild(pre);
};
export interface CreateDictProviderArgs {
dict: ImportedDictionary;
fs: DictionaryFileOpener;
/** Localized label override; defaults to the bundle name. */
label?: string;
}
export const createDictProvider = ({
dict,
fs,
label,
}: CreateDictProviderArgs): DictionaryProvider => {
let reader: DictReader | null = null;
let initPromise: Promise<DictReader> | null = null;
let initError: Error | null = null;
const initOnce = async (): Promise<DictReader> => {
if (reader) return reader;
if (initError) throw initError;
if (!initPromise) {
initPromise = (async () => {
if (!dict.files.index || !dict.files.dict) {
throw new Error('DICT bundle is missing required files');
}
const [indexFile, dictFile] = await Promise.all([
fs.openFile(`${dict.bundleDir}/${dict.files.index}`, 'Dictionaries'),
fs.openFile(`${dict.bundleDir}/${dict.files.dict}`, 'Dictionaries'),
]);
const r = new DictReader();
await r.load({ index: indexFile, dict: dictFile });
reader = r;
return r;
})().catch((err) => {
initError = err instanceof Error ? err : new Error(String(err));
initPromise = null;
throw initError;
});
}
return initPromise;
};
return {
id: dict.id,
kind: 'dict',
label: label ?? dict.name,
async lookup(word, ctx) {
let r: DictReader;
try {
r = await initOnce();
} catch (err) {
return {
ok: false,
reason: 'error',
message: `Failed to load dictionary: ${(err as Error).message}`,
};
}
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
try {
const entry = await r.lookup(word);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
if (!entry) return { ok: false, reason: 'empty' };
const text = await r.readText(entry);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
if (!text) return { ok: false, reason: 'empty' };
renderEntry(ctx.container, entry.word, text);
return {
ok: true,
headword: entry.word,
sourceLabel: r.info.label || dict.name,
};
} catch (err) {
return {
ok: false,
reason: 'error',
message: (err as Error).message,
};
}
},
};
};
@@ -0,0 +1,144 @@
/**
* Slob (Aard 2) provider.
*
* Wraps {@link SlobReader} for the popup. Renders entries based on their
* content type:
* - `text/html*` set as `innerHTML` (Wikipedia/Wiktionary slobs)
* - `text/plain` wrapped `<pre>` with newlines preserved
* - everything else flagged as unsupported (e.g. CSS/JS resources, which
* belong to the slob's bundled stylesheet, not lookup results)
*
* Slob refs starting with `~/` are bundled resource paths (CSS/JS used by
* the dictionary's own templates). They are not user-visible headwords and
* are filtered before binary search hits them.
*/
import type { DictionaryProvider, ImportedDictionary } from '../types';
import type { DictionaryFileOpener } from './starDictProvider';
import { SlobReader } from '../slobReader';
const renderEntry = (
container: HTMLElement,
word: string,
contentType: string,
data: Uint8Array,
): { ok: true } | { ok: false; reason: 'empty' | 'unsupported'; message?: string } => {
if (data.length === 0) return { ok: false, reason: 'empty' };
const ct = contentType.split(';', 1)[0]!.trim().toLowerCase();
const text = new TextDecoder('utf-8').decode(data);
if (ct === 'text/html' || ct === 'application/xhtml+xml') {
const h1 = document.createElement('h1');
h1.textContent = word;
h1.className = 'text-lg font-bold';
container.appendChild(h1);
const div = document.createElement('div');
// Strip <link rel="stylesheet"> tags pointing at slob-internal CSS;
// they 404 because we don't expose the bundled CSS as URLs in the popup.
div.innerHTML = text.replace(/<link\b[^>]*rel=["']?stylesheet[^>]*>/gi, '');
div.className = 'mt-2 text-sm';
container.appendChild(div);
return { ok: true };
}
if (ct === 'text/plain' || ct === '') {
const h1 = document.createElement('h1');
h1.textContent = word;
h1.className = 'text-lg font-bold';
container.appendChild(h1);
const pre = document.createElement('pre');
pre.textContent = text;
pre.className = 'mt-2 whitespace-pre-wrap break-words text-sm font-sans';
container.appendChild(pre);
return { ok: true };
}
return {
ok: false,
reason: 'unsupported',
message: `Slob content type "${ct}" is not supported`,
};
};
export interface CreateSlobProviderArgs {
dict: ImportedDictionary;
fs: DictionaryFileOpener;
/** Localized label override; defaults to the bundle name. */
label?: string;
}
export const createSlobProvider = ({
dict,
fs,
label,
}: CreateSlobProviderArgs): DictionaryProvider => {
let reader: SlobReader | null = null;
let initPromise: Promise<SlobReader> | null = null;
let initError: Error | null = null;
const initOnce = async (): Promise<SlobReader> => {
if (reader) return reader;
if (initError) throw initError;
if (!initPromise) {
initPromise = (async () => {
if (!dict.files.slob) {
throw new Error('Slob bundle is missing the .slob file');
}
const slobFile = await fs.openFile(`${dict.bundleDir}/${dict.files.slob}`, 'Dictionaries');
const r = new SlobReader();
await r.load({ slob: slobFile });
reader = r;
return r;
})().catch((err) => {
initError = err instanceof Error ? err : new Error(String(err));
initPromise = null;
throw initError;
});
}
return initPromise;
};
return {
id: dict.id,
kind: 'slob',
label: label ?? dict.name,
async lookup(word, ctx) {
let r: SlobReader;
try {
r = await initOnce();
} catch (err) {
return {
ok: false,
reason: 'error',
message: `Failed to load dictionary: ${(err as Error).message}`,
};
}
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
try {
// Bundled resources (CSS/JS) live under refs starting with `~/`.
// We don't expose them as lookup hits.
if (word.startsWith('~/')) return { ok: false, reason: 'empty' };
const ref = await r.findRef(word);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
if (!ref) return { ok: false, reason: 'empty' };
if (ref.key.startsWith('~/')) return { ok: false, reason: 'empty' };
const blob = await r.readBlob(ref);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
const outcome = renderEntry(ctx.container, ref.key, blob.contentType, blob.data);
if (!outcome.ok) return outcome;
return {
ok: true,
headword: ref.key,
sourceLabel: r.header.tags['label']?.replace(/\0+$/u, '') || dict.name,
};
} catch (err) {
return {
ok: false,
reason: 'error',
message: (err as Error).message,
};
}
},
};
};
@@ -0,0 +1,80 @@
/**
* Web-search provider.
*
* Renders an "Open in [name]" card inside the popup tab. The `<a href="…">`
* uses the same pattern as `wikipediaProvider`'s "Read on Wikipedia" link,
* so the popup's container click delegation routes the click through
* Tauri's `openUrl` on native and `target="_blank"` works on the web build.
*
* Iframe embedding was considered and rejected Google, Urban Dictionary,
* and Merriam-Webster all set `X-Frame-Options: DENY/SAMEORIGIN`. v1
* pragmatically opens externally; a Tauri-native webview overlay is a
* follow-up if there's demand.
*/
import type { DictionaryProvider, WebSearchEntry } from '../types';
import { substituteUrlTemplate } from '../webSearchTemplates';
import { isTauriAppPlatform } from '@/services/environment';
import { stubTranslation as _ } from '@/utils/misc';
const isTauri = isTauriAppPlatform();
export interface CreateWebSearchProviderArgs {
template: WebSearchEntry;
/** Override the displayed label (e.g. localized built-in name). */
label?: string;
}
export const createWebSearchProvider = ({
template,
label,
}: CreateWebSearchProviderArgs): DictionaryProvider => ({
id: template.id,
kind: 'web',
label: label ?? template.name,
async lookup(word, ctx) {
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
const trimmed = word.trim();
if (!trimmed) return { ok: false, reason: 'empty' };
const url = substituteUrlTemplate(template.urlTemplate, trimmed);
const hgroup = document.createElement('hgroup');
const h1 = document.createElement('h1');
h1.textContent = trimmed;
h1.className = 'text-lg font-bold';
hgroup.append(h1);
const sub = document.createElement('p');
sub.textContent = template.name;
sub.className = 'text-sm italic not-eink:opacity-75';
hgroup.append(sub);
ctx.container.append(hgroup);
const description = document.createElement('p');
description.className = 'mt-3 text-sm';
description.textContent = _('Open the search result in your browser:');
ctx.container.append(description);
const linkWrapper = document.createElement('p');
linkWrapper.className = 'mt-3';
const link = document.createElement('a');
link.href = url;
// Skip target="_blank" on Tauri; the popup's click delegation routes
// through `openUrl`. iOS WebView's _blank handling fails otherwise.
if (!isTauri) link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className =
'btn btn-sm btn-primary normal-case text-primary-content not-eink:no-underline';
// `stubTranslation` is just an extraction marker — the runtime value is
// the key itself. We interpolate the provider name manually.
link.textContent = _('Open in {{name}}').replace('{{name}}', template.name);
linkWrapper.append(link);
ctx.container.append(linkWrapper);
const urlPreview = document.createElement('p');
urlPreview.className = 'mt-3 text-base-content/60 break-all text-xs';
urlPreview.textContent = url;
ctx.container.append(urlPreview);
return { ok: true, headword: trimmed, sourceLabel: template.name };
},
});
@@ -13,12 +13,21 @@
* runtime-only and non-serializable; storing them alongside metadata would
* pollute the synced settings shape.
*/
import type { DictionaryProvider, DictionarySettings, ImportedDictionary } from './types';
import type {
DictionaryProvider,
DictionarySettings,
ImportedDictionary,
WebSearchEntry,
} from './types';
import { BUILTIN_PROVIDER_IDS } from './types';
import { wiktionaryProvider } from './providers/wiktionaryProvider';
import { wikipediaProvider } from './providers/wikipediaProvider';
import { createStarDictProvider, type DictionaryFileOpener } from './providers/starDictProvider';
import { createMdictProvider } from './providers/mdictProvider';
import { createDictProvider } from './providers/dictProvider';
import { createSlobProvider } from './providers/slobProvider';
import { createWebSearchProvider } from './providers/webSearchProvider';
import { getBuiltinWebSearch } from './webSearchTemplates';
const instanceCache = new Map<string, DictionaryProvider>();
@@ -39,10 +48,23 @@ const builtinFor = (id: string): DictionaryProvider | undefined => {
return undefined;
};
/**
* Resolve a `web:*` id to its template built-in if id starts with
* `web:builtin:`, else look it up in `settings.webSearches`.
*/
const findWebTemplate = (id: string, settings: DictionarySettings): WebSearchEntry | undefined => {
if (id.startsWith('web:builtin:')) return getBuiltinWebSearch(id);
const list = settings.webSearches ?? [];
const tpl = list.find((t) => t.id === id);
if (!tpl || tpl.deletedAt) return undefined;
return tpl;
};
const getOrCreate = (
id: string,
dict: ImportedDictionary | undefined,
fs: DictionaryFileOpener | undefined,
settings: DictionarySettings,
): DictionaryProvider | undefined => {
const cached = instanceCache.get(id);
if (cached) return cached;
@@ -51,6 +73,13 @@ const getOrCreate = (
instanceCache.set(id, builtin);
return builtin;
}
if (id.startsWith('web:')) {
const tpl = findWebTemplate(id, settings);
if (!tpl) return undefined;
const provider = createWebSearchProvider({ template: tpl });
instanceCache.set(id, provider);
return provider;
}
if (!dict) return undefined;
if (!fs) return undefined;
if (dict.kind === 'stardict') {
@@ -63,6 +92,16 @@ const getOrCreate = (
instanceCache.set(id, provider);
return provider;
}
if (dict.kind === 'dict') {
const provider = createDictProvider({ dict, fs });
instanceCache.set(id, provider);
return provider;
}
if (dict.kind === 'slob') {
const provider = createSlobProvider({ dict, fs });
instanceCache.set(id, provider);
return provider;
}
return undefined;
};
@@ -83,14 +122,19 @@ export const getEnabledProviders = ({
for (const id of settings.providerOrder) {
if (settings.providerEnabled[id] === false) continue;
if (id.startsWith('builtin:')) {
const provider = getOrCreate(id, undefined, undefined);
const provider = getOrCreate(id, undefined, undefined, settings);
if (provider) out.push(provider);
continue;
}
if (id.startsWith('web:')) {
const provider = getOrCreate(id, undefined, undefined, settings);
if (provider) out.push(provider);
continue;
}
const dict = dictById.get(id);
if (!dict) continue;
if (dict.deletedAt || dict.unavailable || dict.unsupported) continue;
const provider = getOrCreate(id, dict, fs);
const provider = getOrCreate(id, dict, fs, settings);
if (provider) out.push(provider);
}
return out;
@@ -0,0 +1,476 @@
/**
* Slob (Aard 2) reader. Reference: https://github.com/itkach/slob `slob.py`.
*
* # File layout
*
* ```
* header : magic, uuid, encoding, compression, tags, content_types,
* blob_count, store_offset, file_size
* refs : ItemList<Ref> at refs_offset = end of header
* store : ItemList<StoreItem> at store_offset
* ```
*
* ## ItemList format (used for refs and store):
*
* ```
* count : 4 bytes BE (refs only bin/store list count is 4 bytes BE)
* pos[i] : 8 bytes BE × count
* data : items, accessed at data_offset + pos[i]
* ```
*
* ## Ref item:
*
* ```
* key : pascal text, 2-byte BE length
* bin_index : 4 bytes BE
* item_index: 2 bytes BE
* fragment : pascal text, 1-byte length
* ```
*
* ## Store item:
*
* ```
* bin_item_count : 4 bytes BE
* content_type_ids : 1 byte × bin_item_count
* compressed_len : 4 bytes BE
* compressed : bytes (compression algorithm from header)
*
* decompressed:
* pos[i] : 4 bytes BE × bin_item_count
* items : at pos[i]: 4-byte length + content
* ```
*
* # Compression
*
* The header carries a compression name: `zlib`, `bz2`, `lzma2`, or empty.
* v1 supports `zlib` only that's what virtually every Wikipedia/Wiktionary-
* derived slob ships with. `bz2` and `lzma2` are flagged unsupported at
* import time so the popup hides the provider with a clear reason.
*
* # Refs sort order
*
* `slob.py` uses ICU PRIMARY/TERTIARY collation when sorting refs. In
* practice for Latin-only ASCII keys the byte order matches lowercased-
* ASCII order; for accented or non-Latin scripts the orders differ. v1
* does case-insensitive ASCII binary search, plus a tiny linear "near"
* scan around the binary-search bracket to absorb the small ICU/byte
* order mismatches that occur with diacritics. Real Wikipedia/Wiktionary
* slobs lookup hot headwords correctly with this strategy; pure ICU
* collation would require shipping ICU data and isn't worth it for v1.
*/
import { unzlibSync } from 'fflate';
import { LRU } from './dictZip';
const utf8 = new TextDecoder('utf-8');
export interface SlobHeader {
uuid: Uint8Array;
encoding: string;
compression: string;
tags: Record<string, string>;
contentTypes: string[];
blobCount: number;
storeOffset: number;
fileSize: number;
refsOffset: number;
}
export interface SlobRef {
key: string;
binIndex: number;
itemIndex: number;
fragment: string;
}
export interface SlobBlob {
/** Resolved content type, e.g. `text/html;charset=utf-8`. */
contentType: string;
/** Raw decompressed item bytes (not text-decoded — could be CSS, JS, etc.). */
data: Uint8Array;
}
const MAGIC = new Uint8Array([0x21, 0x2d, 0x31, 0x53, 0x4c, 0x4f, 0x42, 0x1f]); // "!-1SLOB\x1F"
class ByteCursor {
pos = 0;
constructor(public readonly buf: Uint8Array) {}
read(n: number): Uint8Array {
const out = this.buf.subarray(this.pos, this.pos + n);
this.pos += n;
return out;
}
byte(): number {
return this.buf[this.pos++]!;
}
u16(): number {
const b0 = this.buf[this.pos]!;
const b1 = this.buf[this.pos + 1]!;
this.pos += 2;
return (b0 << 8) | b1;
}
u32(): number {
const v = readU32BE(this.buf, this.pos);
this.pos += 4;
return v;
}
u64(): number {
const hi = readU32BE(this.buf, this.pos);
const lo = readU32BE(this.buf, this.pos + 4);
this.pos += 8;
// JS numbers are safe up to 2^53; slob files are far smaller.
return hi * 0x1_0000_0000 + lo;
}
/** Pascal-text with `n`-byte BE length prefix. Trailing NULs are trimmed (slob's editable padding). */
text(lenBytes: 1 | 2): string {
const len = lenBytes === 1 ? this.byte() : this.u16();
const bytes = this.read(len);
return decodePascalText(bytes);
}
}
function readU32BE(b: Uint8Array, o: number): number {
return (
(((b[o]! << 24) >>> 0) | ((b[o + 1]! << 16) >>> 0) | ((b[o + 2]! << 8) >>> 0) | b[o + 3]!) >>> 0
);
}
/**
* slob's `_read_text` trims a NUL-padded value when the byte length equals
* the format max (255 for 1-byte length, 65535 for 2-byte). We always trim
* trailing NULs they're meaningless in tag values and only appear due to
* the editable-padding scheme.
*/
function decodePascalText(bytes: Uint8Array): string {
let end = bytes.length;
while (end > 0 && bytes[end - 1] === 0) end--;
return utf8.decode(bytes.subarray(0, end));
}
const cmpAsciiCI = (a: string, b: string): number => {
const x = a.toLowerCase();
const y = b.toLowerCase();
return x < y ? -1 : x > y ? 1 : 0;
};
/** Read header from a Blob. Reads ~8 KB; tags+content types fit comfortably. */
async function readHeader(blob: Blob): Promise<SlobHeader> {
// 64 KB is plenty even for slobs with hundreds of tags / content types.
const headBytes = new Uint8Array(
await blob.slice(0, Math.min(blob.size, 64 * 1024)).arrayBuffer(),
);
const c = new ByteCursor(headBytes);
const magic = c.read(MAGIC.length);
for (let i = 0; i < MAGIC.length; i++) {
if (magic[i] !== MAGIC[i]) throw new Error('Not a Slob file (bad magic)');
}
const uuid = c.read(16);
const encoding = c.text(1);
if (encoding.toLowerCase() !== 'utf-8') {
// slob.py supports any Python codec, but everything in the wild is utf-8.
throw new Error(`Unsupported Slob encoding: ${encoding}`);
}
const compression = c.text(1);
const tagCount = c.byte();
const tags: Record<string, string> = {};
for (let i = 0; i < tagCount; i++) {
const k = c.text(1);
const v = c.text(1);
tags[k] = v;
}
const ctCount = c.byte();
const contentTypes: string[] = [];
for (let i = 0; i < ctCount; i++) contentTypes.push(c.text(2));
const blobCount = c.u32();
const storeOffset = c.u64();
const fileSize = c.u64();
const refsOffset = c.pos;
return {
uuid: new Uint8Array(uuid),
encoding,
compression,
tags,
contentTypes,
blobCount,
storeOffset,
fileSize,
refsOffset,
};
}
export interface SlobReaderOpts {
slob: Blob;
/** Decompressed-bin LRU size. Defaults to 16. */
binCacheSize?: number;
/** Decoded-ref LRU size. Defaults to 1024. */
refCacheSize?: number;
}
export class SlobReader {
header: SlobHeader = {
uuid: new Uint8Array(),
encoding: 'utf-8',
compression: '',
tags: {},
contentTypes: [],
blobCount: 0,
storeOffset: 0,
fileSize: 0,
refsOffset: 0,
};
/** Number of refs (text-key → blob mappings). */
refCount = 0;
private blob: Blob | null = null;
/** ItemList header offset for refs (count then 8-byte positions). */
private refsDataOffset = 0;
/** Position of each ref within the refs data area; one Float64-style entry per ref but stored as two i32s (hi, lo) for >4 GB safety. */
private refPositions: Float64Array = new Float64Array(0);
/** Bin count + per-bin position metadata. */
private binCount = 0;
private binDataOffset = 0;
private binPositions: Float64Array = new Float64Array(0);
private refCache: LRU<number, SlobRef>;
private binCache: LRU<number, { contentTypeIds: Uint8Array; raw: Uint8Array }>;
constructor(opts?: { binCacheSize?: number; refCacheSize?: number }) {
this.refCache = new LRU<number, SlobRef>(opts?.refCacheSize ?? 1024);
this.binCache = new LRU<number, { contentTypeIds: Uint8Array; raw: Uint8Array }>(
opts?.binCacheSize ?? 16,
);
}
async load(opts: SlobReaderOpts): Promise<void> {
if (opts.refCacheSize !== undefined) {
this.refCache = new LRU<number, SlobRef>(opts.refCacheSize);
}
if (opts.binCacheSize !== undefined) {
this.binCache = new LRU<number, { contentTypeIds: Uint8Array; raw: Uint8Array }>(
opts.binCacheSize,
);
}
this.blob = opts.slob;
this.header = await readHeader(opts.slob);
if (this.header.compression !== 'zlib') {
throw new Error(
`Unsupported Slob compression "${this.header.compression}". v1 supports zlib only.`,
);
}
if (this.header.fileSize !== opts.slob.size) {
throw new Error(
`Slob file size mismatch: header says ${this.header.fileSize}, file is ${opts.slob.size}`,
);
}
// Refs ItemList: 4-byte count, then count × u64 positions.
const refsHeader = new Uint8Array(
await opts.slob.slice(this.header.refsOffset, this.header.refsOffset + 4).arrayBuffer(),
);
this.refCount = readU32BE(refsHeader, 0);
const refPosStart = this.header.refsOffset + 4;
const refPosBytes = new Uint8Array(
await opts.slob.slice(refPosStart, refPosStart + this.refCount * 8).arrayBuffer(),
);
this.refPositions = new Float64Array(this.refCount);
for (let i = 0; i < this.refCount; i++) {
const o = i * 8;
const hi = readU32BE(refPosBytes, o);
const lo = readU32BE(refPosBytes, o + 4);
this.refPositions[i] = hi * 0x1_0000_0000 + lo;
}
this.refsDataOffset = refPosStart + this.refCount * 8;
// Store ItemList: 4-byte bin_count, then bin_count × u64 positions.
const storeHeader = new Uint8Array(
await opts.slob.slice(this.header.storeOffset, this.header.storeOffset + 4).arrayBuffer(),
);
this.binCount = readU32BE(storeHeader, 0);
const binPosStart = this.header.storeOffset + 4;
const binPosBytes = new Uint8Array(
await opts.slob.slice(binPosStart, binPosStart + this.binCount * 8).arrayBuffer(),
);
this.binPositions = new Float64Array(this.binCount);
for (let i = 0; i < this.binCount; i++) {
const o = i * 8;
const hi = readU32BE(binPosBytes, o);
const lo = readU32BE(binPosBytes, o + 4);
this.binPositions[i] = hi * 0x1_0000_0000 + lo;
}
this.binDataOffset = binPosStart + this.binCount * 8;
}
/**
* Decode one ref. Each ref is variable-length: 2-byte BE key length + key
* bytes + 4-byte BE bin_index + 2-byte BE item_index + 1-byte fragment
* length + fragment bytes. We read a generous slice (max key 65535 +
* max fragment 255 + 7 fixed bytes), then truncate.
*/
private async decodeRef(i: number): Promise<SlobRef> {
const cached = this.refCache.get(i);
if (cached) return cached;
if (!this.blob) throw new Error('slob blob not loaded');
const start = this.refsDataOffset + this.refPositions[i]!;
// Maximum ref size: 2 + 65535 + 4 + 2 + 1 + 255 = 65799. Realistic
// headwords are ≤ 200 bytes; reading 4 KB covers virtually all of
// them with one Blob slice.
const slice = new Uint8Array(
await this.blob.slice(start, Math.min(start + 4 * 1024, this.blob.size)).arrayBuffer(),
);
let pos = 0;
const keyLen = (slice[pos]! << 8) | slice[pos + 1]!;
pos += 2;
let keyBytes: Uint8Array;
if (pos + keyLen <= slice.length) {
keyBytes = slice.subarray(pos, pos + keyLen);
pos += keyLen;
} else {
// Tail spilled past our 4 KB read — fall back to a precise re-read.
const need = 2 + keyLen + 4 + 2 + 1 + 255;
const exact = new Uint8Array(
await this.blob.slice(start, Math.min(start + need, this.blob.size)).arrayBuffer(),
);
keyBytes = exact.subarray(2, 2 + keyLen);
pos = 2 + keyLen;
const binIndex = readU32BE(exact, pos);
pos += 4;
const itemIndex = (exact[pos]! << 8) | exact[pos + 1]!;
pos += 2;
const fragLen = exact[pos]!;
pos += 1;
const fragment = decodePascalText(exact.subarray(pos, pos + fragLen));
const ref: SlobRef = {
key: decodePascalText(keyBytes),
binIndex,
itemIndex,
fragment,
};
this.refCache.set(i, ref);
return ref;
}
const binIndex = readU32BE(slice, pos);
pos += 4;
const itemIndex = (slice[pos]! << 8) | slice[pos + 1]!;
pos += 2;
const fragLen = slice[pos]!;
pos += 1;
const fragment = decodePascalText(slice.subarray(pos, pos + fragLen));
const ref: SlobRef = {
key: decodePascalText(keyBytes),
binIndex,
itemIndex,
fragment,
};
this.refCache.set(i, ref);
return ref;
}
/** Look up the first ref whose key case-insensitively matches `word`. */
async findRef(word: string): Promise<SlobRef | undefined> {
if (!this.refCount) return undefined;
let lo = 0;
let hi = this.refCount - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const r = await this.decodeRef(mid);
const cmp = cmpAsciiCI(word, r.key);
if (cmp === 0) return r;
if (cmp > 0) lo = mid + 1;
else hi = mid - 1;
}
// Binary search misses can happen when the slob was sorted with ICU
// collation (locale-aware) but our key compare is byte-wise: keys with
// diacritics or non-ASCII codepoints may end up adjacent to but not
// exactly at the binary-search bracket. Scan a tiny window.
const NEAR_WINDOW = 8;
const wlo = Math.max(0, lo - NEAR_WINDOW);
const whi = Math.min(this.refCount - 1, lo + NEAR_WINDOW);
for (let i = wlo; i <= whi; i++) {
const r = await this.decodeRef(i);
if (cmpAsciiCI(word, r.key) === 0) return r;
}
return undefined;
}
/**
* Decompress a bin and cache it. The bin lays out as:
* bin_item_count : u32 BE
* ct_ids : 1 byte × bin_item_count
* compressed_len : u32 BE
* compressed : bytes
*/
private async getBin(binIndex: number): Promise<{ contentTypeIds: Uint8Array; raw: Uint8Array }> {
const cached = this.binCache.get(binIndex);
if (cached) return cached;
if (!this.blob) throw new Error('slob blob not loaded');
const start = this.binDataOffset + this.binPositions[binIndex]!;
// Read the small leading header first to learn bin_item_count + comp_len.
const headSlice = new Uint8Array(
await this.blob.slice(start, Math.min(start + 4 * 1024, this.blob.size)).arrayBuffer(),
);
const binItemCount = readU32BE(headSlice, 0);
if (4 + binItemCount + 4 > headSlice.length) {
// 4 KB wasn't enough — re-read precise size.
const need = 4 + binItemCount + 4;
const exact = new Uint8Array(await this.blob.slice(start, start + need).arrayBuffer());
// Use exact for the lengths only; we still need to read the body.
const ctIds = exact.subarray(4, 4 + binItemCount);
const compLen = readU32BE(exact, 4 + binItemCount);
const compStart = start + 4 + binItemCount + 4;
const comp = new Uint8Array(
await this.blob.slice(compStart, compStart + compLen).arrayBuffer(),
);
const raw = unzlibSync(comp);
const out = { contentTypeIds: ctIds.slice(), raw };
this.binCache.set(binIndex, out);
return out;
}
const ctIds = headSlice.subarray(4, 4 + binItemCount);
const compLen = readU32BE(headSlice, 4 + binItemCount);
const compStartInSlice = 4 + binItemCount + 4;
let compBytes: Uint8Array;
if (compStartInSlice + compLen <= headSlice.length) {
compBytes = headSlice.subarray(compStartInSlice, compStartInSlice + compLen);
} else {
const compStart = start + compStartInSlice;
compBytes = new Uint8Array(
await this.blob.slice(compStart, compStart + compLen).arrayBuffer(),
);
}
const raw = unzlibSync(compBytes);
const out = { contentTypeIds: ctIds.slice(), raw };
this.binCache.set(binIndex, out);
return out;
}
/** Read the blob referenced by a ref. */
async readBlob(ref: SlobRef): Promise<SlobBlob> {
const bin = await this.getBin(ref.binIndex);
const itemCount = bin.contentTypeIds.length;
if (ref.itemIndex >= itemCount) {
throw new Error(`Slob item_index ${ref.itemIndex} out of range (bin has ${itemCount})`);
}
// Decompressed bin: u32 BE positions × itemCount, then items.
const pos = readU32BE(bin.raw, ref.itemIndex * 4);
const dataOffset = itemCount * 4;
const itemStart = dataOffset + pos;
const itemLen = readU32BE(bin.raw, itemStart);
const data = bin.raw.subarray(itemStart + 4, itemStart + 4 + itemLen);
const ctId = bin.contentTypeIds[ref.itemIndex]!;
const contentType = this.header.contentTypes[ctId] ?? '';
return { contentType, data };
}
/** Number of refs — exposed for tests. */
get refsCount(): number {
return this.refCount;
}
}
/** Strip trailing NULs from header tag values (slob editable-padding). */
export const cleanTag = (v: string): string => v.replace(/\0+$/u, '');
@@ -53,7 +53,7 @@
* Init heap after: ~420 KB (idx offsets) + chunk metadata (~few KB)
* + LRU chunk cache ( ~1 MB after warmup).
*/
import { gunzipSync, Inflate } from 'fflate';
import { LRU, loadDictBody, type DictBody } from './dictZip';
export interface StarDictEntry {
word: string;
@@ -63,11 +63,6 @@ export interface StarDictEntry {
const decoder = new TextDecoder('utf-8');
const GZIP_MAGIC = [0x1f, 0x8b];
const isGzip = (bytes: Uint8Array): boolean =>
bytes.length >= 2 && bytes[0] === GZIP_MAGIC[0] && bytes[1] === GZIP_MAGIC[1];
/** Parse the key=value `.ifo` text into a record. */
export function parseIfo(text: string): Record<string, string> {
const out: Record<string, string> = {};
@@ -161,200 +156,9 @@ const cmpAscii = (a: string, b: string): number => {
return x < y ? -1 : x > y ? 1 : 0;
};
/**
* Bounded LRU. Tiny enough we don't bother with a real linked-list
* implementation; reusing the Map insertion order is fine.
*/
class LRU<K, V> {
private readonly max: number;
private readonly map = new Map<K, V>();
constructor(max: number) {
this.max = max;
}
get(key: K): V | undefined {
const v = this.map.get(key);
if (v !== undefined) {
// Move to end (most-recently-used).
this.map.delete(key);
this.map.set(key, v);
}
return v;
}
set(key: K, value: V): void {
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.max) {
// Evict oldest.
const first = this.map.keys().next().value as K | undefined;
if (first !== undefined) this.map.delete(first);
}
}
}
// ---------------------------------------------------------------------------
// DictZip parsing + lazy chunk decompression.
//
// A `.dict.dz` file is a standard gzip with a FEXTRA "RA" subfield
// listing the compressed length of each chunk. Each chunk's uncompressed
// length is exactly `chlen` (the last may be shorter). Chunks are
// separated by `Z_FULL_FLUSH` (BFINAL=0 + sync marker), so each chunk's
// deflate stream is non-terminating — `inflateSync` rejects them with
// "unexpected EOF". fflate's streaming `Inflate.push(bytes, false)`
// handles them correctly and emits chunk bytes via `ondata`.
// ---------------------------------------------------------------------------
interface DictZipMeta {
/** Uncompressed bytes per chunk (last may be shorter). */
chlen: number;
/** Compressed bytes per chunk; sums to `compressedDataSize`. */
chunkSizes: number[];
/** Byte offset within the file where compressed chunk data begins. */
compressedDataOffset: number;
}
/**
* Parse the gzip header + FEXTRA RA subfield. Returns `null` for
* non-gzip files or gzip files without an RA subfield (those need
* whole-file gunzip).
*/
function parseDictZipHeader(bytes: Uint8Array): DictZipMeta | null {
if (bytes.length < 12 || bytes[0] !== 0x1f || bytes[1] !== 0x8b || bytes[2] !== 0x08) {
return null;
}
const flg = bytes[3]!;
const hasFEXTRA = (flg & 0b100) !== 0;
if (!hasFEXTRA) return null;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const xlen = view.getUint16(10, true);
let chlen = 0;
let chcnt = 0;
const chunkSizes: number[] = [];
let p = 12;
while (p + 4 <= 12 + xlen) {
const si1 = bytes[p]!;
const si2 = bytes[p + 1]!;
const slen = view.getUint16(p + 2, true);
if (si1 === 0x52 && si2 === 0x41) {
// RA subfield: ver(2) + chlen(2) + chcnt(2) + chcnt × chunkSize(2).
const ver = view.getUint16(p + 4, true);
if (ver !== 1) return null;
chlen = view.getUint16(p + 6, true);
chcnt = view.getUint16(p + 8, true);
for (let i = 0; i < chcnt; i++) {
chunkSizes.push(view.getUint16(p + 10 + 2 * i, true));
}
}
p += 4 + slen;
}
if (chcnt === 0 || chunkSizes.length !== chcnt) return null;
// Skip past FEXTRA + optional FNAME, FCOMMENT, FHCRC.
let offset = 12 + xlen;
if (flg & 0b1000) {
while (offset < bytes.length && bytes[offset] !== 0) offset++;
offset++;
}
if (flg & 0b10000) {
while (offset < bytes.length && bytes[offset] !== 0) offset++;
offset++;
}
if (flg & 0b10) offset += 2;
return { chlen, chunkSizes, compressedDataOffset: offset };
}
/**
* Inflate a single DictZip chunk via fflate's streaming `Inflate`. The
* chunk's deflate stream ends at `Z_FULL_FLUSH` (BFINAL=0 `inflateSync`
* would reject), so we push with `final=false` and capture the data
* emitted by `ondata`. Returns `null` on failure.
*/
function inflateChunkStreaming(chunkBytes: Uint8Array): Uint8Array | null {
let result: Uint8Array | null = null;
try {
const inf = new Inflate();
inf.ondata = (data, _final) => {
// The first `ondata` carries this chunk's uncompressed bytes; any
// subsequent calls would be from feeding further input (we don't).
if (result === null) result = data;
};
inf.push(chunkBytes, false);
} catch {
return null;
}
if (!result || (result as Uint8Array).length === 0) return null;
return result;
}
class DictZipChunkedDict {
private blob: Blob;
private meta: DictZipMeta;
private chunkOffsets: number[];
/** Total uncompressed dict size, derived from chunk count × chlen (last chunk may be shorter; we don't know its exact size yet). */
private chunkCache: LRU<number, Uint8Array>;
constructor(blob: Blob, meta: DictZipMeta, cacheSize: number) {
this.blob = blob;
this.meta = meta;
this.chunkCache = new LRU<number, Uint8Array>(cacheSize);
// Precompute starting compressed offset of each chunk for slice reads.
const offsets: number[] = [];
let acc = meta.compressedDataOffset;
for (const cs of meta.chunkSizes) {
offsets.push(acc);
acc += cs;
}
this.chunkOffsets = offsets;
}
/** Read `size` uncompressed bytes starting at `offset`. */
async read(offset: number, size: number): Promise<Uint8Array> {
const chlen = this.meta.chlen;
const startChunk = Math.floor(offset / chlen);
const endChunk = Math.floor((offset + size - 1) / chlen);
if (startChunk === endChunk) {
const chunk = await this.getChunk(startChunk);
const local = offset - startChunk * chlen;
return chunk.subarray(local, local + size);
}
// Spans multiple chunks — concatenate.
const parts: Uint8Array[] = [];
let totalLen = 0;
for (let i = startChunk; i <= endChunk; i++) {
const chunk = await this.getChunk(i);
parts.push(chunk);
totalLen += chunk.length;
}
const combined = new Uint8Array(totalLen);
let pos = 0;
for (const p of parts) {
combined.set(p, pos);
pos += p.length;
}
const local = offset - startChunk * chlen;
return combined.subarray(local, local + size);
}
private async getChunk(i: number): Promise<Uint8Array> {
const cached = this.chunkCache.get(i);
if (cached) return cached;
const start = this.chunkOffsets[i]!;
const compressedSize = this.meta.chunkSizes[i]!;
const compressed = new Uint8Array(
await this.blob.slice(start, start + compressedSize).arrayBuffer(),
);
const inflated = inflateChunkStreaming(compressed);
if (!inflated) throw new Error(`Failed to inflate DictZip chunk ${i}`);
this.chunkCache.set(i, inflated);
return inflated;
}
}
// DictZip parsing + lazy chunk decompression has been extracted to
// `dictZip.ts` so the DICT (dictd) reader can share the same code path. The
// `loadDictBody` factory below replaces the old per-class probe + fallback.
export interface StarDictReaderOpts {
ifo: Blob;
@@ -377,12 +181,10 @@ export interface StarDictReaderOpts {
export class StarDictReader {
ifo: Record<string, string> = {};
// Dict-reading mode. Exactly one of these is populated at init:
// - `dictChunked`: lazy chunk decompression (proper DictZip files).
// - `dictBuffer`: whole-file gunzip in memory (fallback when FEXTRA
// is absent or chunk-probe fails).
private dictChunked: DictZipChunkedDict | null = null;
private dictBuffer: Uint8Array = new Uint8Array();
// Dict body — a `DictBody` from `dictZip.ts` that internally chooses lazy
// chunk decompression for proper DictZip files and falls back to whole-
// file gunzip when the FEXTRA is absent or the probe fails.
private body: DictBody | null = null;
// .idx state — populated eagerly at init.
private idxBlob: Blob | null = null;
@@ -414,17 +216,10 @@ export class StarDictReader {
this.synCache = new LRU<number, { syn: string; idxIndex: number }>(cacheSize);
}
// Read the small files in parallel. We deliberately skip
// `opts.dict.arrayBuffer()` here — the dict is read in fragments
// below depending on whether lazy chunk mode is viable.
const [ifoBuf, idxOffsetsBuf, synOffsetsBuf, dictHeadBuf] = await Promise.all([
const [ifoBuf, idxOffsetsBuf, synOffsetsBuf] = await Promise.all([
opts.ifo.arrayBuffer(),
opts.idxOffsets ? opts.idxOffsets.arrayBuffer() : Promise.resolve(undefined),
opts.synOffsets ? opts.synOffsets.arrayBuffer() : Promise.resolve(undefined),
// Read just the gzip header + FEXTRA region (much less than the
// whole file). 64 KB is enormously generous for a FEXTRA RA
// subfield — even thousands of chunks would fit in a few KB.
opts.dict.slice(0, Math.min(opts.dict.size, 64 * 1024)).arrayBuffer(),
]);
this.ifo = parseIfo(decoder.decode(new Uint8Array(ifoBuf)));
@@ -448,27 +243,9 @@ export class StarDictReader {
this.idxCount = idxOffsets.length;
this.idxBlob = opts.idx;
// Try lazy chunk mode for `.dict.dz`. Parse FEXTRA, probe-inflate
// chunk 0; on success, retain only the metadata + Blob. On failure,
// fall back to whole-file gunzip.
const dictHead = new Uint8Array(dictHeadBuf);
if (isGzip(dictHead)) {
const meta = parseDictZipHeader(dictHead);
if (meta && (await this.probeChunkInflate(opts.dict, meta))) {
this.dictChunked = new DictZipChunkedDict(
opts.dict,
meta,
/* chunk LRU size */ Math.max(8, Math.floor(cacheSize / 16)),
);
} else {
// FEXTRA missing or chunk probe failed — gunzip the whole file.
const dictBytes = new Uint8Array(await opts.dict.arrayBuffer());
this.dictBuffer = gunzipSync(dictBytes);
}
} else {
// Raw .dict (no compression): keep bytes as-is.
this.dictBuffer = new Uint8Array(await opts.dict.arrayBuffer());
}
this.body = await loadDictBody(opts.dict, {
chunkCacheSize: Math.max(8, Math.floor(cacheSize / 16)),
});
// .syn: keep the Blob, accept its sidecar eagerly if provided. If not,
// the offset table is built lazily on first synonym fallback.
@@ -485,32 +262,10 @@ export class StarDictReader {
}
}
/**
* Read chunk 0 of a candidate DictZip and try to inflate it via the
* streaming inflater. If that succeeds and produces chlen bytes, the
* file's chunks are independently decompressible lazy mode is viable.
*/
private async probeChunkInflate(blob: Blob, meta: DictZipMeta): Promise<boolean> {
if (meta.chunkSizes.length === 0) return false;
const cs = meta.chunkSizes[0]!;
const start = meta.compressedDataOffset;
const compressed = new Uint8Array(await blob.slice(start, start + cs).arrayBuffer());
const inflated = inflateChunkStreaming(compressed);
return !!inflated && inflated.length > 0 && inflated.length <= meta.chlen;
}
/**
* Resolve an entry's bytes from the dict.
*
* Lazy-chunk mode: reads the chunks containing the entry's uncompressed
* range from the dict Blob and streaming-inflates them.
* Whole-file mode: in-memory subarray.
*/
/** Resolve an entry's bytes from the dict body. */
async read(entry: StarDictEntry): Promise<Uint8Array> {
if (this.dictChunked) {
return this.dictChunked.read(entry.offset, entry.size);
}
return this.dictBuffer.subarray(entry.offset, entry.offset + entry.size);
if (!this.body) throw new Error('dict body not loaded');
return this.body.read(entry.offset, entry.size);
}
/** Number of entries — exposed for tests. */
@@ -7,7 +7,7 @@
* order; each provider writes lookup output into a per-tab container.
*/
export type DictionaryProviderKind = 'builtin' | 'stardict' | 'mdict';
export type DictionaryProviderKind = 'builtin' | 'stardict' | 'mdict' | 'dict' | 'slob' | 'web';
export interface DictionaryLookupContext {
/** Source language hint, e.g. book primary language code (`en`, `zh`). */
@@ -48,13 +48,14 @@ export interface DictionaryProvider {
*/
export interface ImportedDictionary {
id: string;
kind: 'stardict' | 'mdict';
/** Display name, derived from `.ifo` `bookname` or `.mdx` header `Title`. */
kind: 'stardict' | 'mdict' | 'dict' | 'slob';
/** Display name, derived from `.ifo` `bookname`, `.mdx` `Title`, slob `label`, or DICT `00databaseshort`. */
name: string;
/** Subdirectory under `'Dictionaries'` containing this bundle's files. */
bundleDir: string;
/** Filenames inside `bundleDir`. The exact set varies by `kind`. */
files: {
// StarDict bundle.
ifo?: string;
idx?: string;
dict?: string;
@@ -67,8 +68,14 @@ export interface ImportedDictionary {
idxOffsets?: string;
/** Same idea for `.syn`. */
synOffsets?: string;
// MDict bundle.
mdx?: string;
mdd?: string[];
// DICT (dictd) bundle. `dict` above doubles as the body filename
// (`name.dict` or `name.dict.dz`); `index` is the dictd `.index` file.
index?: string;
// Slob bundle: a single self-contained `.slob` file.
slob?: string;
};
/** Source language code if known. */
lang?: string;
@@ -92,6 +99,28 @@ export interface ImportedDictionary {
unsupportedReason?: string;
}
/**
* A web-search "provider" template a URL with a `%WORD%` placeholder
* (URL-encoded substitution at lookup time). Built-in templates (Google,
* Urban Dictionary, Merriam-Webster) are hardcoded in the registry and
* reference the IDs in {@link BUILTIN_WEB_SEARCH_IDS}; user-added templates
* live in {@link DictionarySettings.webSearches} with IDs of the form
* `web:<uniqueId>`.
*
* Web-search providers don't fetch upstream the popup just renders an
* "Open in [name]" link that opens the resolved URL externally. Iframe
* embedding is blocked by every major dictionary site (X-Frame-Options).
*/
export interface WebSearchEntry {
id: string;
/** Display name shown in the tab strip and the settings list. */
name: string;
/** URL with `%WORD%` placeholder, e.g. `https://example.com/?q=%WORD%`. */
urlTemplate: string;
/** Soft-delete marker; only set on user-added entries. */
deletedAt?: number;
}
export interface DictionarySettings {
/** Provider id order shown in the popup tab strip. Includes builtin ids. */
providerOrder: string[];
@@ -99,6 +128,11 @@ export interface DictionarySettings {
providerEnabled: Record<string, boolean>;
/** Last-used tab id; `undefined` falls back to first enabled provider. */
defaultProviderId?: string;
/**
* User-defined web search templates. Built-in templates (Google, Urban,
* Merriam-Webster) are hardcoded in the registry and not stored here.
*/
webSearches?: WebSearchEntry[];
}
/** Stable ids for the built-in providers. */
@@ -108,3 +142,18 @@ export const BUILTIN_PROVIDER_IDS = {
} as const;
export type BuiltinProviderId = (typeof BUILTIN_PROVIDER_IDS)[keyof typeof BUILTIN_PROVIDER_IDS];
/**
* Stable ids for the built-in web-search templates. The `web:builtin:*`
* prefix lets the registry recognize and dispatch them without a settings
* lookup; user-added templates live in `settings.webSearches` with ids of
* the form `web:<uniqueId>`.
*/
export const BUILTIN_WEB_SEARCH_IDS = {
google: 'web:builtin:google',
urban: 'web:builtin:urban',
merriamWebster: 'web:builtin:merriam-webster',
} as const;
export type BuiltinWebSearchId =
(typeof BUILTIN_WEB_SEARCH_IDS)[keyof typeof BUILTIN_WEB_SEARCH_IDS];
@@ -0,0 +1,63 @@
/**
* Built-in web-search templates.
*
* Three universally-useful entries (Google, Urban Dictionary,
* Merriam-Webster). They are seeded into `providerOrder` but **disabled by
* default** users opt in from the settings list. Custom URL templates
* live in `settings.webSearches`; this file is only the immutable
* built-ins.
*
* The `%WORD%` placeholder marks where the looked-up word goes; the
* provider URL-encodes it at substitution time. (GoldenDict users may know
* `%GDWORD%` from there we use `%WORD%` for brevity, but accept neither
* verbatim variant beyond what's documented here.)
*/
import { BUILTIN_WEB_SEARCH_IDS, type WebSearchEntry } from './types';
export interface BuiltinWebSearchTemplate extends WebSearchEntry {
/** Localizable display name key — passed through `_(...)` at render time. */
nameKey: string;
}
export const BUILTIN_WEB_SEARCHES: BuiltinWebSearchTemplate[] = [
{
id: BUILTIN_WEB_SEARCH_IDS.google,
name: 'Google',
nameKey: 'Google',
urlTemplate: 'https://www.google.com/search?q=define:%WORD%&hl=en',
},
{
id: BUILTIN_WEB_SEARCH_IDS.urban,
name: 'Urban Dictionary',
nameKey: 'Urban Dictionary',
urlTemplate: 'https://www.urbandictionary.com/define.php?term=%WORD%',
},
{
id: BUILTIN_WEB_SEARCH_IDS.merriamWebster,
name: 'Merriam-Webster',
nameKey: 'Merriam-Webster',
urlTemplate: 'https://www.merriam-webster.com/dictionary/%WORD%',
},
];
const BUILTIN_BY_ID = new Map(BUILTIN_WEB_SEARCHES.map((t) => [t.id, t]));
export const getBuiltinWebSearch = (id: string): BuiltinWebSearchTemplate | undefined =>
BUILTIN_BY_ID.get(id);
/**
* Substitute `%WORD%` (case-insensitive) in a URL template with the
* URL-encoded form of `word`. Also handles the doubly-escaped form
* `%25WORD%25` that may appear if a user pasted a URL through a tool that
* percent-encoded the placeholder.
*/
export const substituteUrlTemplate = (template: string, word: string): string => {
const encoded = encodeURIComponent(word);
return template.replace(/%25WORD%25/gi, encoded).replace(/%WORD%/gi, encoded);
};
/** Returns true if a template contains a usable `%WORD%` placeholder. */
export const isValidUrlTemplate = (template: string): boolean => {
if (!/^https?:\/\//i.test(template.trim())) return false;
return /%WORD%/i.test(template) || /%25WORD%25/i.test(template);
};
@@ -1,15 +1,39 @@
import { create } from 'zustand';
import { EnvConfigType } from '@/services/environment';
import type { DictionarySettings, ImportedDictionary } from '@/services/dictionaries/types';
import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
import type {
DictionarySettings,
ImportedDictionary,
WebSearchEntry,
} from '@/services/dictionaries/types';
import { BUILTIN_PROVIDER_IDS, BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types';
import { useSettingsStore } from './settingsStore';
/**
* Built-in web-search ids are seeded into `providerOrder` but disabled by
* default users opt in. This preserves the principle that we don't push
* users onto external pages without consent, while still surfacing the
* options in the settings list.
*/
const BUILTIN_WEB_ORDER = [
BUILTIN_WEB_SEARCH_IDS.google,
BUILTIN_WEB_SEARCH_IDS.urban,
BUILTIN_WEB_SEARCH_IDS.merriamWebster,
];
const DEFAULT_DICTIONARY_SETTINGS: DictionarySettings = {
providerOrder: [BUILTIN_PROVIDER_IDS.wiktionary, BUILTIN_PROVIDER_IDS.wikipedia],
providerOrder: [
BUILTIN_PROVIDER_IDS.wiktionary,
BUILTIN_PROVIDER_IDS.wikipedia,
...BUILTIN_WEB_ORDER,
],
providerEnabled: {
[BUILTIN_PROVIDER_IDS.wiktionary]: true,
[BUILTIN_PROVIDER_IDS.wikipedia]: true,
[BUILTIN_WEB_SEARCH_IDS.google]: false,
[BUILTIN_WEB_SEARCH_IDS.urban]: false,
[BUILTIN_WEB_SEARCH_IDS.merriamWebster]: false,
},
webSearches: [],
};
interface DictionaryStoreState {
@@ -32,6 +56,13 @@ interface DictionaryStoreState {
/** Persist the last-used tab id so the popup re-opens on it. */
setDefaultProviderId(id: string | undefined): void;
/** Add a custom web search (id is generated). Appended + enabled by default. */
addWebSearch(name: string, urlTemplate: string): WebSearchEntry;
/** Update an existing custom web search; no-op if id is unknown or built-in. */
updateWebSearch(id: string, patch: { name?: string; urlTemplate?: string }): void;
/** Soft-delete a custom web search and remove from order/enabled. */
removeWebSearch(id: string): boolean;
/** Hydrate from `settings.customDictionaries` + `settings.dictionarySettings` + check on-disk availability. */
loadCustomDictionaries(envConfig: EnvConfigType): Promise<void>;
/** Persist current state back into settings (which then syncs to cloud). */
@@ -123,6 +154,66 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
}));
},
addWebSearch: (name, urlTemplate) => {
const trimmedName = name.trim();
const trimmedUrl = urlTemplate.trim();
const id = `web:${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
const entry: WebSearchEntry = { id, name: trimmedName, urlTemplate: trimmedUrl };
set((state) => {
const list = state.settings.webSearches ?? [];
const order = state.settings.providerOrder.includes(id)
? state.settings.providerOrder
: [...state.settings.providerOrder, id];
const enabled = { ...state.settings.providerEnabled, [id]: true };
return {
settings: {
...state.settings,
webSearches: [...list, entry],
providerOrder: order,
providerEnabled: enabled,
},
};
});
return entry;
},
updateWebSearch: (id, patch) => {
if (id.startsWith('web:builtin:')) return;
set((state) => {
const list = state.settings.webSearches ?? [];
if (!list.some((t) => t.id === id)) return state;
const next = list.map((t) =>
t.id === id
? {
...t,
name: patch.name?.trim() ?? t.name,
urlTemplate: patch.urlTemplate?.trim() ?? t.urlTemplate,
}
: t,
);
return { settings: { ...state.settings, webSearches: next } };
});
},
removeWebSearch: (id) => {
if (id.startsWith('web:builtin:')) return false;
const list = get().settings.webSearches ?? [];
if (!list.some((t) => t.id === id)) return false;
set((state) => ({
settings: {
...state.settings,
webSearches: (state.settings.webSearches ?? []).map((t) =>
t.id === id ? { ...t, deletedAt: Date.now() } : t,
),
providerOrder: state.settings.providerOrder.filter((p) => p !== id),
providerEnabled: Object.fromEntries(
Object.entries(state.settings.providerEnabled).filter(([k]) => k !== id),
),
},
}));
return true;
},
loadCustomDictionaries: async (envConfig) => {
try {
const { settings } = useSettingsStore.getState();
@@ -137,15 +228,25 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
}),
);
// Merge defaults to back-fill any missing keys (e.g. new builtin added in a release).
// For providerOrder, we append any newly-defaulted ids (like the
// built-in web searches added in this release) so existing users see
// them appear at the end of the list.
const persistedOrder = persistedSettings.providerOrder;
const orderSet = new Set(persistedOrder);
const merged: string[] = persistedOrder.length
? [...persistedOrder]
: [...DEFAULT_DICTIONARY_SETTINGS.providerOrder];
for (const id of DEFAULT_DICTIONARY_SETTINGS.providerOrder) {
if (!orderSet.has(id)) merged.push(id);
}
const settingsMerged: DictionarySettings = {
providerOrder: persistedSettings.providerOrder.length
? persistedSettings.providerOrder
: DEFAULT_DICTIONARY_SETTINGS.providerOrder,
providerOrder: merged,
providerEnabled: {
...DEFAULT_DICTIONARY_SETTINGS.providerEnabled,
...persistedSettings.providerEnabled,
},
defaultProviderId: persistedSettings.defaultProviderId,
webSearches: persistedSettings.webSearches ?? [],
};
set({ dictionaries, settings: settingsMerged });
} catch (error) {