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:
@@ -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 l’import.",
|
||||
@@ -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 l’importazione.",
|
||||
@@ -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}} 中開啟"
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
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) {
|
||||
|
||||
Reference in New Issue
Block a user