feat(dict): improve MDict rendering and dictionary management (#4072)

* fix(reader): play sound:// links in MDict definitions via MDD lookup

MDX entries reference audio resources with `<a href="sound://name.ext">`.
Until now those anchors fell through to the browser, which tried to
navigate to an invalid scheme and did nothing useful.

Wire each `sound://` anchor inside the rendered MDX body to:
- preventDefault + stopPropagation (so the parent card's tap-to-expand
  doesn't fire),
- look up the path in every companion `.mdd` until one returns bytes
  (js-mdict's `MDD.locateBytes` auto-normalizes the leading separator),
- wrap the bytes in a Blob and play via `new Audio(URL.createObjectURL)`,
- cache the resolved URL on the anchor so subsequent clicks reuse it,
  with the URL tracked for revocation in `dispose()`.

Note: many MW-style dictionaries use `.spx` (Speex) which Chromium and
Safari don't natively decode — the lookup will succeed but playback may
fail silently. Other formats (mp3, wav, ogg vorbis) play fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dict): improve MDict rendering and dictionary management

Builds on the sound:// fix to round out MDict rendering and tighten
the dictionary settings panel.

MDict provider:
- Follow MDict-specific URL schemes inside the rendered HTML:
  `sound://path` plays via Audio (with a deprecation toast for `.spx`
  whose codec no major browser decodes), and `entry://word` /
  `bword://word` forward to ctx.onNavigate so the popup re-looks-up
  the target. Cycle-bounded (5 hops) `@@@LINK=<word>` content-level
  redirects are followed transparently, so entries that are pure
  redirect strings (e.g. "questions" → "question") render the
  canonical entry instead of the literal redirect text.
- Render the body inside a shadow root so each dict's CSS stays
  scoped — `<link rel="stylesheet">` references are resolved against
  the companion .mdd, loose .css files imported alongside the bundle
  are read at init, and `url(...)` refs inside both are rewritten to
  blob URLs sourced from the MDD (covers sound icons, background
  images, @font-face sources). The body is tagged `data-dict-kind="mdict"`
  for downstream targeting.
- A baseline app-level stylesheet (`getDictStyles`) is injected into
  every shadow root with theme-adaptive `mix-blend-mode` for `<a>`
  background icons / `<a> img` (multiply on light, screen on dark);
  isDarkMode is forwarded via the lookup context.
- `<img src="/path">` is now treated as MDD-relative (the tightened
  IMG_SRC_PROTOCOL_RX skips schemes / protocol-relative only); a
  fallback retry strips the leading slash for bundles that store the
  resource without it.
- The auto-prepended light-DOM headword `<h1>` is hidden when the
  dict body either leads with a same-text element (any tag — covers
  `<h3 class="entry_name">`, etc.) or contains an `<h1>` with the same
  trimmed text anywhere (covers wrapper-div-then-h1 layouts).

Dictionary management:
- Importing a dict whose name matches an existing one now replaces
  it in place, preserving the slot in providerOrder and inheriting
  the previous enabled flag. The .css extension is added to the file
  picker, and loose .css files imported alongside .mdx/.mdd are
  bundled with the dictionary regardless of stem-match.
- The settings panel gains an Edit mode (parity with Delete mode):
  trailing pencil button on imported dicts and custom web searches
  opens a rename modal. Edit and Delete are mutually exclusive.
  Below 400px, the Edit/Delete labels collapse to icons only.

Card UX:
- The card's tap-to-expand handler now walks `composedPath()` so
  clicks on anchors / buttons / images inside the shadow root no
  longer fold the card.

i18n:
- Translations added for new strings across 33 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-06 14:27:58 +08:00
committed by GitHub
parent a272ba892a
commit 30dee7b909
46 changed files with 1824 additions and 346 deletions
@@ -876,7 +876,6 @@
"Failed to export the book.": "فشل تصدير الكتاب.",
"Export Book": "تصدير الكتاب",
"Whole word:": "كلمة كاملة:",
"Error": "خطأ",
"Date Published": "تاريخ النشر",
"Only for TTS:": "فقط لـ TTS:",
"Uploaded": "تم الرفع",
@@ -1279,11 +1278,6 @@
"Failed to start stream": "فشل بدء البث",
"No dictionaries enabled": "لا توجد قواميس مفعلة",
"Enable a dictionary in Settings → Language → Dictionaries.": "فعّل قاموسًا من الإعدادات ← اللغة ← القواميس.",
"No definitions found": "لم يتم العثور على تعريفات",
"Search for {{word}} on the web.": "ابحث عن {{word}} على الويب.",
"Dictionary unsupported": "القاموس غير مدعوم",
"This dictionary format is not supported yet.": "تنسيق هذا القاموس غير مدعوم بعد.",
"Unable to load the word.": "تعذّر تحميل الكلمة.",
"Wiktionary": "ويكاموس",
"Drag to reorder": "اسحب لإعادة الترتيب",
"Built-in": "مدمج",
@@ -1305,7 +1299,6 @@
"Import Dictionary": "استيراد قاموس",
"No dictionaries available.": "لا تتوفر قواميس.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "تحتاج حزم StarDict إلى ملفات .ifo و‎.idx و‎.dict.dz (الـ‎.syn اختياري).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "حزم MDict تستخدم ملفات ‎.mdx؛ ملفات ‎.mdd المرافقة اختيارية.",
"Select all the bundle files together when importing.": "حدد جميع ملفات الحزمة معًا عند الاستيراد.",
"Manage Dictionaries": "إدارة القواميس",
"Select Dictionary Files": "اختر ملفات القاموس",
@@ -1420,5 +1413,20 @@
"Use %WORD% where the looked-up word should appear.": "استخدم %WORD% حيث يجب أن تظهر الكلمة المبحوث عنها.",
"Open the search result in your browser:": "افتح نتيجة البحث في متصفحك:",
"Open in {{name}}": "افتح في {{name}}",
"Book Fingerprint": "بصمة الكتاب"
"Book Fingerprint": "بصمة الكتاب",
"Search the web": "البحث في الويب",
"App deeplink (readest://)": "رابط التطبيق (readest://)",
"Universal web link (https://)": "رابط ويب شامل (https://)",
"Name cannot be empty.": "لا يمكن أن يكون الاسم فارغًا.",
"Replaced {{count}} existing dictionary_zero": "لم يتم استبدال أي قاموس",
"Replaced {{count}} existing dictionary_one": "تم استبدال قاموس واحد",
"Replaced {{count}} existing dictionary_two": "تم استبدال قاموسين",
"Replaced {{count}} existing dictionary_few": "تم استبدال {{count}} قواميس",
"Replaced {{count}} existing dictionary_many": "تم استبدال {{count}} قاموسًا",
"Replaced {{count}} existing dictionary_other": "تم استبدال {{count}} قاموس",
"Cancel Edit": "إلغاء التعديل",
"Edit Dictionary": "تعديل القاموس",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "تستخدم حزم MDict ملفات .mdx؛ ملفات .mdd و .css المرافقة اختيارية.",
"Dictionary name": "اسم القاموس",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "لا يمكن تشغيل هذا الصوت هنا — يستخدم القاموس تنسيقًا قديمًا. جرّب قاموسًا يستخدم صوت Opus أو MP3 أو WAV."
}
@@ -836,7 +836,6 @@
"Failed to export the book.": "বই রপ্তানি করতে ব্যর্থ।",
"Export Book": "বই রপ্তানি করুন",
"Whole word:": "সম্পূর্ণ শব্দ:",
"Error": "ত্রুটি",
"Date Published": "প্রকাশনার তারিখ",
"Only for TTS:": "শুধুমাত্র TTS-এর জন্য:",
"Uploaded": "আপলোড হয়েছে",
@@ -1211,11 +1210,6 @@
"Failed to start stream": "স্ট্রিম শুরু করতে ব্যর্থ",
"No dictionaries enabled": "কোনো অভিধান সক্রিয় নেই",
"Enable a dictionary in Settings → Language → Dictionaries.": "সেটিংস → ভাষা → অভিধান থেকে একটি অভিধান সক্রিয় করুন।",
"No definitions found": "কোনো সংজ্ঞা পাওয়া যায়নি",
"Search for {{word}} on the web.": "ওয়েবে {{word}} খুঁজুন।",
"Dictionary unsupported": "অভিধান সমর্থিত নয়",
"This dictionary format is not supported yet.": "এই অভিধান বিন্যাস এখনো সমর্থিত নয়।",
"Unable to load the word.": "শব্দটি লোড করা যায়নি।",
"Wiktionary": "উইক্‌শনারি",
"Drag to reorder": "পুনঃক্রম করতে টানুন",
"Built-in": "বিল্ট-ইন",
@@ -1233,7 +1227,6 @@
"Import Dictionary": "অভিধান আমদানি করুন",
"No dictionaries available.": "কোনো অভিধান উপলব্ধ নেই।",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict বান্ডলে .ifo, .idx এবং .dict.dz ফাইল প্রয়োজন (.syn ঐচ্ছিক)।",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict বান্ডলে .mdx ফাইল ব্যবহৃত হয়; সঙ্গী .mdd ফাইল ঐচ্ছিক।",
"Select all the bundle files together when importing.": "আমদানির সময় বান্ডলের সব ফাইল একসঙ্গে নির্বাচন করুন।",
"Manage Dictionaries": "অভিধান পরিচালনা",
"Select Dictionary Files": "অভিধান ফাইল নির্বাচন করুন",
@@ -1328,5 +1321,16 @@
"Use %WORD% where the looked-up word should appear.": "যেখানে অনুসন্ধান করা শব্দ উপস্থিত হওয়া উচিত সেখানে %WORD% ব্যবহার করুন।",
"Open the search result in your browser:": "আপনার ব্রাউজারে অনুসন্ধান ফলাফল খুলুন:",
"Open in {{name}}": "{{name}}-এ খুলুন",
"Book Fingerprint": "বইয়ের ফিঙ্গারপ্রিন্ট"
"Book Fingerprint": "বইয়ের ফিঙ্গারপ্রিন্ট",
"Search the web": "ওয়েবে অনুসন্ধান করুন",
"App deeplink (readest://)": "অ্যাপ ডিপলিংক (readest://)",
"Universal web link (https://)": "সর্বজনীন ওয়েব লিঙ্ক (https://)",
"Name cannot be empty.": "নাম খালি রাখা যাবে না।",
"Replaced {{count}} existing dictionary_one": "{{count}}টি বিদ্যমান অভিধান প্রতিস্থাপিত হয়েছে",
"Replaced {{count}} existing dictionary_other": "{{count}}টি বিদ্যমান অভিধান প্রতিস্থাপিত হয়েছে",
"Cancel Edit": "সম্পাদনা বাতিল করুন",
"Edit Dictionary": "অভিধান সম্পাদনা করুন",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict বান্ডেল .mdx ফাইল ব্যবহার করে; সহগামী .mdd ও .css ফাইল ঐচ্ছিক।",
"Dictionary name": "অভিধানের নাম",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "এই অডিও এখানে চালানো যাচ্ছে না — অভিধানটি একটি পুরনো ফর্ম্যাট ব্যবহার করছে। Opus, MP3 বা WAV অডিও সহ একটি ব্যবহার করে দেখুন।"
}
@@ -826,7 +826,6 @@
"Failed to export the book.": "དཔེ་དེབ་ཕྱིར་འདོན་མི་ཐུབ།",
"Export Book": "དཔེ་དེབ་ཕྱིར་འདོན།",
"Whole word:": "ཚིག་གྲུབ་ཆ་ཚང་:",
"Error": "ནོར་འཁྲུལ།",
"Date Published": "པར་སྐྲུན་ཚེས་གྲངས།",
"Only for TTS:": "TTS ལ་ཁོ་ན།:",
"Uploaded": "ཡར་སྐྱེལ་བྱས་ཟིན།",
@@ -1194,11 +1193,6 @@
"Failed to start stream": "རྒྱུན་སྤེལ་འགོ་འཛུགས་མ་ཐུབ།",
"No dictionaries enabled": "ཚིག་མཛོད་གང་ཡང་སྒོ་མི་འབྱེད།",
"Enable a dictionary in Settings → Language → Dictionaries.": "སྒྲིག་འགོད ← སྐད་ཡིག ← ཚིག་མཛོད་ནས་ཚིག་མཛོད་ཤིག་ལ་སྒོ་འབྱེད།",
"No definitions found": "གོ་དོན་མི་རྙེད།",
"Search for {{word}} on the web.": "དྲ་ཐོག་ཏུ་ {{word}} ཞིབ་འཚོལ།",
"Dictionary unsupported": "ཚིག་མཛོད་འདི་རྒྱབ་སྐྱོར་མི་བྱེད།",
"This dictionary format is not supported yet.": "ཚིག་མཛོད་འདིའི་རྣམ་པར་ད་དུང་རྒྱབ་སྐྱོར་མི་བྱེད།",
"Unable to load the word.": "ཚིག་འདི་འཇུག་ཐུབ་མ་སོང༌།",
"Wiktionary": "ཝི་ཀེ་ཤོག་ནར།",
"Drag to reorder": "གོ་རིམ་སྒྱུར་བར་འཐེན།",
"Built-in": "ནང་ཁུལ།",
@@ -1215,7 +1209,6 @@
"Import Dictionary": "ཚིག་མཛོད་ནང་འདྲེན།",
"No dictionaries available.": "ཚིག་མཛོད་གང་ཡང་མི་འདུག།",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict ཡོ་བྱད་ལ་ .ifo དང་ .idx, .dict.dz ཡིག་ཆ་ཁག་དགོས་པ་ཡིན། (.syn ནི་འདེམས་འཁྱོངས་ཡིན།)",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict ཡོ་བྱད་ཀྱིས་ .mdx ཡིག་ཆ་སྤྱོད། མཉམ་འགྲོགས་ .mdd ཡིག་ཆ་ནི་འདེམས་འཁྱོངས་ཡིན།",
"Select all the bundle files together when importing.": "ནང་འདྲེན་སྐབས་ཡོ་བྱད་ཡིག་ཆ་ཡོངས་མཉམ་དུ་འདེམས་རོགས།",
"Manage Dictionaries": "ཚིག་མཛོད་སྟངས་འཛིན།",
"Select Dictionary Files": "ཚིག་མཛོད་ཡིག་ཆ་འདེམས།",
@@ -1305,5 +1298,15 @@
"Use %WORD% where the looked-up word should appear.": "འཚོལ་བཤེར་བྱས་པའི་ཚིག་འཆར་ས་གནས་སུ་ %WORD% བཀོལ་སྤྱོད་གནང་རོགས།",
"Open the search result in your browser:": "ཁྱེད་ཀྱི་བརྡ་འཚོལ་མཁོ་ཆས་ལ་འཚོལ་བཤེར་གྲུབ་འབྲས་ཁ་ཕྱེ:",
"Open in {{name}}": "{{name}} ནང་ཁ་ཕྱེ།",
"Book Fingerprint": "དཔེ་ཆའི་མཛུབ་རྗེས།"
"Book Fingerprint": "དཔེ་ཆའི་མཛུབ་རྗེས།",
"Search the web": "དྲ་ངོས་སུ་འཚོལ་ཞིབ།",
"App deeplink (readest://)": "ཉེར་སྤྱོད་ཟབ་སྦྲེལ (readest://)",
"Universal web link (https://)": "སྤྱི་ཁྱབ་དྲ་སྦྲེལ (https://)",
"Name cannot be empty.": "མིང་སྟོང་པར་འཇོག་མི་རུང་།",
"Replaced {{count}} existing dictionary_other": "གཞི་ནས་ཡོད་པའི་ཚིག་མཛོད་ {{count}} བརྗེ་སྒྱུར་བྱས།",
"Cancel Edit": "བཟོ་བཅོས་ཕྱིར་འཐེན།",
"Edit Dictionary": "ཚིག་མཛོད་བཟོ་བཅོས།",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict གི་ཡིག་ཆས་ཚོགས་ཀྱིས་ .mdx ཡིག་ཆ་སྤྱོད། སྦྲེལ་མཐུན་ .mdd དང་ .css ཡིག་ཆ་ཡིན་ན་འདེམས་ཆོག",
"Dictionary name": "ཚིག་མཛོད་མིང་།",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "སྒྲ་འདི་འདིར་སྒྲོག་མི་ཐུབ — ཚིག་མཛོད་འདིས་རྩིས་མེད་པའི་རྣམ་པ་སྤྱོད། Opus, MP3, ཡང་ན་ WAV གི་སྒྲ་ལྡན་པ་ཞིག་ལ་འདེམས་སྡུར་གྱིས།"
}
@@ -836,7 +836,6 @@
"Failed to export the book.": "Buch konnte nicht exportiert werden.",
"Export Book": "Buch exportieren",
"Whole word:": "Ganzes Wort:",
"Error": "Fehler",
"Date Published": "Veröffentlichungsdatum",
"Only for TTS:": "Nur für TTS:",
"Uploaded": "Hochgeladen",
@@ -1211,11 +1210,6 @@
"Failed to start stream": "Stream konnte nicht gestartet werden",
"No dictionaries enabled": "Keine Wörterbücher aktiviert",
"Enable a dictionary in Settings → Language → Dictionaries.": "Aktiviere ein Wörterbuch unter Einstellungen → Sprache → Wörterbücher.",
"No definitions found": "Keine Definitionen gefunden",
"Search for {{word}} on the web.": "Suche {{word}} im Web.",
"Dictionary unsupported": "Wörterbuch nicht unterstützt",
"This dictionary format is not supported yet.": "Dieses Wörterbuchformat wird noch nicht unterstützt.",
"Unable to load the word.": "Wort konnte nicht geladen werden.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Zum Neuanordnen ziehen",
"Built-in": "Integriert",
@@ -1233,7 +1227,6 @@
"Import Dictionary": "Wörterbuch importieren",
"No dictionaries available.": "Keine Wörterbücher verfügbar.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict-Bundles benötigen .ifo-, .idx- und .dict.dz-Dateien (.syn optional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict-Bundles verwenden .mdx-Dateien; begleitende .mdd-Dateien sind optional.",
"Select all the bundle files together when importing.": "Wähle beim Import alle Bundle-Dateien zusammen aus.",
"Manage Dictionaries": "Wörterbücher verwalten",
"Select Dictionary Files": "Wörterbuchdateien auswählen",
@@ -1328,5 +1321,16 @@
"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",
"Book Fingerprint": "Buch-Fingerabdruck"
"Book Fingerprint": "Buch-Fingerabdruck",
"Search the web": "Im Web suchen",
"App deeplink (readest://)": "App-Deeplink (readest://)",
"Universal web link (https://)": "Universeller Weblink (https://)",
"Name cannot be empty.": "Name darf nicht leer sein.",
"Replaced {{count}} existing dictionary_one": "{{count}} bestehendes Wörterbuch ersetzt",
"Replaced {{count}} existing dictionary_other": "{{count}} bestehende Wörterbücher ersetzt",
"Cancel Edit": "Bearbeiten abbrechen",
"Edit Dictionary": "Wörterbuch bearbeiten",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict-Bundles verwenden .mdx-Dateien; begleitende .mdd- und .css-Dateien sind optional.",
"Dictionary name": "Wörterbuchname",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Diese Audio kann hier nicht abgespielt werden — das Wörterbuch verwendet ein veraltetes Format. Probieren Sie ein Wörterbuch mit Opus-, MP3- oder WAV-Audio."
}
@@ -836,7 +836,6 @@
"Failed to export the book.": "Αποτυχία εξαγωγής του βιβλίου.",
"Export Book": "Εξαγωγή βιβλίου",
"Whole word:": "Ολόκληρη λέξη:",
"Error": "Σφάλμα",
"Date Published": "Ημερομηνία δημοσίευσης",
"Only for TTS:": "Μόνο για TTS:",
"Uploaded": "Μεταφορτώθηκε",
@@ -1211,11 +1210,6 @@
"Failed to start stream": "Αποτυχία έναρξης ροής",
"No dictionaries enabled": "Δεν είναι ενεργοποιημένο κανένα λεξικό",
"Enable a dictionary in Settings → Language → Dictionaries.": "Ενεργοποιήστε ένα λεξικό από Ρυθμίσεις → Γλώσσα → Λεξικά.",
"No definitions found": "Δεν βρέθηκαν ορισμοί",
"Search for {{word}} on the web.": "Αναζήτηση {{word}} στον ιστό.",
"Dictionary unsupported": "Το λεξικό δεν υποστηρίζεται",
"This dictionary format is not supported yet.": "Αυτή η μορφή λεξικού δεν υποστηρίζεται ακόμα.",
"Unable to load the word.": "Δεν ήταν δυνατή η φόρτωση της λέξης.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Σύρετε για επαναταξινόμηση",
"Built-in": "Ενσωματωμένο",
@@ -1233,7 +1227,6 @@
"Import Dictionary": "Εισαγωγή λεξικού",
"No dictionaries available.": "Δεν υπάρχουν διαθέσιμα λεξικά.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Τα πακέτα StarDict χρειάζονται αρχεία .ifo, .idx και .dict.dz (το .syn είναι προαιρετικό).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Τα πακέτα MDict χρησιμοποιούν αρχεία .mdx· τα συνοδευτικά .mdd είναι προαιρετικά.",
"Select all the bundle files together when importing.": "Επιλέξτε όλα τα αρχεία του πακέτου μαζί κατά την εισαγωγή.",
"Manage Dictionaries": "Διαχείριση λεξικών",
"Select Dictionary Files": "Επιλογή αρχείων λεξικού",
@@ -1328,5 +1321,16 @@
"Use %WORD% where the looked-up word should appear.": "Χρησιμοποιήστε %WORD% όπου πρέπει να εμφανίζεται η λέξη αναζήτησης.",
"Open the search result in your browser:": "Ανοίξτε το αποτέλεσμα αναζήτησης στο πρόγραμμα περιήγησής σας:",
"Open in {{name}}": "Άνοιγμα σε {{name}}",
"Book Fingerprint": "Δακτυλικό αποτύπωμα βιβλίου"
"Book Fingerprint": "Δακτυλικό αποτύπωμα βιβλίου",
"Search the web": "Αναζήτηση στον ιστό",
"App deeplink (readest://)": "Σύνδεσμος εφαρμογής (readest://)",
"Universal web link (https://)": "Γενικός σύνδεσμος ιστού (https://)",
"Name cannot be empty.": "Το όνομα δεν μπορεί να είναι κενό.",
"Replaced {{count}} existing dictionary_one": "Αντικαταστάθηκε {{count}} υπάρχον λεξικό",
"Replaced {{count}} existing dictionary_other": "Αντικαταστάθηκαν {{count}} υπάρχοντα λεξικά",
"Cancel Edit": "Ακύρωση επεξεργασίας",
"Edit Dictionary": "Επεξεργασία λεξικού",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Τα πακέτα MDict χρησιμοποιούν αρχεία .mdx· τα συνοδευτικά αρχεία .mdd και .css είναι προαιρετικά.",
"Dictionary name": "Όνομα λεξικού",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Αυτός ο ήχος δεν μπορεί να αναπαραχθεί εδώ — το λεξικό χρησιμοποιεί ξεπερασμένη μορφή. Δοκιμάστε ένα λεξικό με ήχο Opus, MP3 ή WAV."
}
@@ -40,6 +40,8 @@
"Failed to sync {{count}} OPDS catalog(s)_other": "Failed to sync {{count}} OPDS catalogs",
"Imported {{count}} dictionary_one": "Imported {{count}} dictionary",
"Imported {{count}} dictionary_other": "Imported {{count}} dictionaries",
"Replaced {{count}} existing dictionary_one": "Replaced {{count}} existing dictionary",
"Replaced {{count}} existing dictionary_other": "Replaced {{count}} existing dictionaries",
"{{count}} books refreshed_one": "{{count}} book refreshed",
"{{count}} books refreshed_other": "{{count}} books refreshed",
"{{count}} failed_one": "{{count}} failed",
@@ -846,7 +846,6 @@
"Failed to export the book.": "Error al exportar el libro.",
"Export Book": "Exportar libro",
"Whole word:": "Palabra completa:",
"Error": "Error",
"Date Published": "Fecha de publicación",
"Only for TTS:": "Solo para TTS:",
"Uploaded": "Subido",
@@ -1228,11 +1227,6 @@
"Failed to start stream": "No se pudo iniciar el streaming",
"No dictionaries enabled": "No hay diccionarios habilitados",
"Enable a dictionary in Settings → Language → Dictionaries.": "Habilita un diccionario en Ajustes → Idioma → Diccionarios.",
"No definitions found": "No se encontraron definiciones",
"Search for {{word}} on the web.": "Buscar {{word}} en la web.",
"Dictionary unsupported": "Diccionario no compatible",
"This dictionary format is not supported yet.": "Este formato de diccionario aún no es compatible.",
"Unable to load the word.": "No se pudo cargar la palabra.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Arrastrar para reordenar",
"Built-in": "Integrado",
@@ -1251,7 +1245,6 @@
"Import Dictionary": "Importar diccionario",
"No dictionaries available.": "No hay diccionarios disponibles.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Los paquetes StarDict requieren archivos .ifo, .idx y .dict.dz (.syn opcional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Los paquetes MDict usan archivos .mdx; los .mdd asociados son opcionales.",
"Select all the bundle files together when importing.": "Selecciona todos los archivos del paquete juntos al importar.",
"Manage Dictionaries": "Administrar diccionarios",
"Select Dictionary Files": "Seleccionar archivos de diccionario",
@@ -1351,5 +1344,17 @@
"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}}",
"Book Fingerprint": "Huella del libro"
"Book Fingerprint": "Huella del libro",
"Search the web": "Buscar en la web",
"App deeplink (readest://)": "Enlace profundo de la app (readest://)",
"Universal web link (https://)": "Enlace web universal (https://)",
"Name cannot be empty.": "El nombre no puede estar vacío.",
"Replaced {{count}} existing dictionary_one": "Se reemplazó {{count}} diccionario existente",
"Replaced {{count}} existing dictionary_many": "Se reemplazaron {{count}} diccionarios existentes",
"Replaced {{count}} existing dictionary_other": "Se reemplazaron {{count}} diccionarios existentes",
"Cancel Edit": "Cancelar edición",
"Edit Dictionary": "Editar diccionario",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Los paquetes MDict usan archivos .mdx; los archivos .mdd y .css complementarios son opcionales.",
"Dictionary name": "Nombre del diccionario",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este audio no se puede reproducir aquí — el diccionario usa un formato obsoleto. Prueba uno con audio Opus, MP3 o WAV."
}
@@ -836,7 +836,6 @@
"Failed to export the book.": "صادر کردن کتاب ناموفق بود.",
"Export Book": "صادر کردن کتاب",
"Whole word:": "کلمه کامل:",
"Error": "خطا",
"Date Published": "تاریخ انتشار",
"Only for TTS:": "فقط برای TTS:",
"Uploaded": "بارگذاری شد",
@@ -1211,11 +1210,6 @@
"Failed to start stream": "شروع پخش ناموفق بود",
"No dictionaries enabled": "هیچ فرهنگ‌لغتی فعال نیست",
"Enable a dictionary in Settings → Language → Dictionaries.": "فرهنگ‌لغتی را از تنظیمات ← زبان ← فرهنگ‌لغت‌ها فعال کنید.",
"No definitions found": "هیچ معنی‌ای یافت نشد",
"Search for {{word}} on the web.": "جست‌وجوی {{word}} در وب.",
"Dictionary unsupported": "فرهنگ‌لغت پشتیبانی نمی‌شود",
"This dictionary format is not supported yet.": "این قالب فرهنگ‌لغت هنوز پشتیبانی نمی‌شود.",
"Unable to load the word.": "بارگذاری واژه ممکن نشد.",
"Wiktionary": "ویکی‌واژه",
"Drag to reorder": "برای تغییر ترتیب بکشید",
"Built-in": "داخلی",
@@ -1233,7 +1227,6 @@
"Import Dictionary": "وارد کردن فرهنگ‌لغت",
"No dictionaries available.": "هیچ فرهنگ‌لغتی در دسترس نیست.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "بسته‌های StarDict به فایل‌های .ifo، .idx و .dict.dz نیاز دارند (.syn اختیاری).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "بسته‌های MDict از فایل‌های .mdx استفاده می‌کنند؛ فایل‌های همراه .mdd اختیاری‌اند.",
"Select all the bundle files together when importing.": "هنگام وارد کردن، همه فایل‌های بسته را با هم انتخاب کنید.",
"Manage Dictionaries": "مدیریت فرهنگ‌لغت‌ها",
"Select Dictionary Files": "انتخاب فایل‌های فرهنگ‌لغت",
@@ -1328,5 +1321,16 @@
"Use %WORD% where the looked-up word should appear.": "از %WORD% در جایی که کلمه‌ی مورد جست‌وجو باید ظاهر شود، استفاده کنید.",
"Open the search result in your browser:": "نتیجه‌ی جست‌وجو را در مرورگر باز کنید:",
"Open in {{name}}": "بازکردن در {{name}}",
"Book Fingerprint": "اثر انگشت کتاب"
"Book Fingerprint": "اثر انگشت کتاب",
"Search the web": "جستجو در وب",
"App deeplink (readest://)": "پیوند درون‌برنامه‌ای (readest://)",
"Universal web link (https://)": "پیوند وب جهانی (https://)",
"Name cannot be empty.": "نام نمی‌تواند خالی باشد.",
"Replaced {{count}} existing dictionary_one": "{{count}} فرهنگ‌نامه موجود جایگزین شد",
"Replaced {{count}} existing dictionary_other": "{{count}} فرهنگ‌نامه موجود جایگزین شدند",
"Cancel Edit": "لغو ویرایش",
"Edit Dictionary": "ویرایش فرهنگ‌نامه",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "بسته‌های MDict از فایل‌های .mdx استفاده می‌کنند؛ فایل‌های همراه .mdd و .css اختیاری هستند.",
"Dictionary name": "نام فرهنگ‌نامه",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "این صدا اینجا قابل پخش نیست — فرهنگ‌نامه از قالبی قدیمی استفاده می‌کند. فرهنگ‌نامه‌ای با صدای Opus، MP3 یا WAV را امتحان کنید."
}
@@ -846,7 +846,6 @@
"Failed to export the book.": "Échec de l'exportation du livre.",
"Export Book": "Exporter le livre",
"Whole word:": "Mot entier :",
"Error": "Erreur",
"Date Published": "Date de publication",
"Only for TTS:": "Uniquement pour TTS :",
"Uploaded": "Téléversé",
@@ -1228,11 +1227,6 @@
"Failed to start stream": "Échec du démarrage du flux",
"No dictionaries enabled": "Aucun dictionnaire activé",
"Enable a dictionary in Settings → Language → Dictionaries.": "Activez un dictionnaire dans Paramètres → Langue → Dictionnaires.",
"No definitions found": "Aucune définition trouvée",
"Search for {{word}} on the web.": "Rechercher {{word}} sur le web.",
"Dictionary unsupported": "Dictionnaire non pris en charge",
"This dictionary format is not supported yet.": "Ce format de dictionnaire nest pas encore pris en charge.",
"Unable to load the word.": "Impossible de charger le mot.",
"Wiktionary": "Wiktionnaire",
"Drag to reorder": "Glisser pour réorganiser",
"Built-in": "Intégré",
@@ -1251,7 +1245,6 @@
"Import Dictionary": "Importer un dictionnaire",
"No dictionaries available.": "Aucun dictionnaire disponible.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Les paquets StarDict nécessitent des fichiers .ifo, .idx et .dict.dz (.syn optionnel).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Les paquets MDict utilisent des fichiers .mdx ; les .mdd associés sont optionnels.",
"Select all the bundle files together when importing.": "Sélectionnez tous les fichiers du paquet ensemble lors de limport.",
"Manage Dictionaries": "Gérer les dictionnaires",
"Select Dictionary Files": "Sélectionner les fichiers du dictionnaire",
@@ -1351,5 +1344,17 @@
"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}}",
"Book Fingerprint": "Empreinte du livre"
"Book Fingerprint": "Empreinte du livre",
"Search the web": "Rechercher sur le web",
"App deeplink (readest://)": "Lien profond de lapplication (readest://)",
"Universal web link (https://)": "Lien web universel (https://)",
"Name cannot be empty.": "Le nom ne peut pas être vide.",
"Replaced {{count}} existing dictionary_one": "{{count}} dictionnaire existant remplacé",
"Replaced {{count}} existing dictionary_many": "{{count}} dictionnaires existants remplacés",
"Replaced {{count}} existing dictionary_other": "{{count}} dictionnaires existants remplacés",
"Cancel Edit": "Annuler la modification",
"Edit Dictionary": "Modifier le dictionnaire",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Les bundles MDict utilisent des fichiers .mdx ; les fichiers .mdd et .css associés sont facultatifs.",
"Dictionary name": "Nom du dictionnaire",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Cet audio ne peut pas être lu ici — le dictionnaire utilise un format obsolète. Essayez-en un avec un audio Opus, MP3 ou WAV."
}
@@ -387,7 +387,6 @@
"System Language": "שפת המערכת",
"No translation available.": "אין תרגום זמין.",
"Translated by {{provider}}.": "תורגם על ידי {{provider}}.",
"Error": "שגיאה",
"Remove Bookmark": "הסר סימנייה",
"Add Bookmark": "הוסף סימנייה",
"Books Content": "תוכן ספרים",
@@ -1228,11 +1227,6 @@
"Failed to start stream": "הפעלת הזרם נכשלה",
"No dictionaries enabled": "לא הופעלו מילונים",
"Enable a dictionary in Settings → Language → Dictionaries.": "הפעילו מילון מההגדרות ← שפה ← מילונים.",
"No definitions found": "לא נמצאו הגדרות",
"Search for {{word}} on the web.": "חיפוש {{word}} באינטרנט.",
"Dictionary unsupported": "מילון לא נתמך",
"This dictionary format is not supported yet.": "תבנית המילון הזו אינה נתמכת עדיין.",
"Unable to load the word.": "לא ניתן לטעון את המילה.",
"Wiktionary": "ויקימילון",
"Drag to reorder": "גררו כדי לסדר מחדש",
"Built-in": "מובנה",
@@ -1251,7 +1245,6 @@
"Import Dictionary": "ייבא מילון",
"No dictionaries available.": "אין מילונים זמינים.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "חבילות StarDict דורשות קבצי .ifo, .idx ו-.dict.dz (.syn אופציונלי).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "חבילות MDict משתמשות בקבצי .mdx; קבצי .mdd נלווים הם אופציונליים.",
"Select all the bundle files together when importing.": "בחרו את כל קבצי החבילה יחד בעת הייבוא.",
"Manage Dictionaries": "ניהול מילונים",
"Select Dictionary Files": "בחירת קבצי מילון",
@@ -1351,5 +1344,17 @@
"Use %WORD% where the looked-up word should appear.": "השתמש ב-%WORD% היכן שהמילה המבוקשת צריכה להופיע.",
"Open the search result in your browser:": "פתח את תוצאת החיפוש בדפדפן שלך:",
"Open in {{name}}": "פתח ב-{{name}}",
"Book Fingerprint": "טביעת אצבע של הספר"
"Book Fingerprint": "טביעת אצבע של הספר",
"Search the web": "חיפוש באינטרנט",
"App deeplink (readest://)": "קישור עמוק לאפליקציה (readest://)",
"Universal web link (https://)": "קישור אינטרנט אוניברסלי (https://)",
"Name cannot be empty.": "השם אינו יכול להיות ריק.",
"Replaced {{count}} existing dictionary_one": "הוחלף {{count}} מילון קיים",
"Replaced {{count}} existing dictionary_two": "הוחלפו {{count}} מילונים קיימים",
"Replaced {{count}} existing dictionary_other": "הוחלפו {{count}} מילונים קיימים",
"Cancel Edit": "בטל עריכה",
"Edit Dictionary": "ערוך מילון",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "חבילות MDict משתמשות בקובצי .mdx; קובצי .mdd ו‑.css נלווים הם אופציונליים.",
"Dictionary name": "שם המילון",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "לא ניתן להשמיע את השמע הזה כאן — המילון משתמש בפורמט מיושן. נסה מילון עם שמע Opus, MP3 או WAV."
}
@@ -836,7 +836,6 @@
"Failed to export the book.": "पुस्तक निर्यात करने में विफल।",
"Export Book": "पुस्तक निर्यात करें",
"Whole word:": "पूरा शब्द:",
"Error": "त्रुटि",
"Date Published": "प्रकाशन तिथि",
"Only for TTS:": "केवल TTS के लिए:",
"Uploaded": "अपलोड किया गया",
@@ -1211,11 +1210,6 @@
"Failed to start stream": "स्ट्रीम प्रारंभ करने में विफल",
"No dictionaries enabled": "कोई शब्दकोश सक्षम नहीं है",
"Enable a dictionary in Settings → Language → Dictionaries.": "सेटिंग्स → भाषा → शब्दकोश में जाकर शब्दकोश सक्षम करें।",
"No definitions found": "कोई परिभाषा नहीं मिली",
"Search for {{word}} on the web.": "वेब पर {{word}} खोजें।",
"Dictionary unsupported": "शब्दकोश समर्थित नहीं है",
"This dictionary format is not supported yet.": "यह शब्दकोश प्रारूप अभी समर्थित नहीं है।",
"Unable to load the word.": "शब्द लोड नहीं किया जा सका।",
"Wiktionary": "विक्षनरी",
"Drag to reorder": "पुनः क्रमित करने के लिए खींचें",
"Built-in": "अंतर्निर्मित",
@@ -1233,7 +1227,6 @@
"Import Dictionary": "शब्दकोश आयात करें",
"No dictionaries available.": "कोई शब्दकोश उपलब्ध नहीं है।",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict बंडल को .ifo, .idx और .dict.dz फ़ाइलें चाहिए (.syn वैकल्पिक)।",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict बंडल .mdx फ़ाइलों का उपयोग करते हैं; साथी .mdd फ़ाइलें वैकल्पिक हैं।",
"Select all the bundle files together when importing.": "आयात करते समय बंडल की सभी फ़ाइलें एक साथ चुनें।",
"Manage Dictionaries": "शब्दकोश प्रबंधित करें",
"Select Dictionary Files": "शब्दकोश फ़ाइलें चुनें",
@@ -1328,5 +1321,16 @@
"Use %WORD% where the looked-up word should appear.": "%WORD% का उपयोग वहां करें जहां खोजा गया शब्द दिखाई देना चाहिए।",
"Open the search result in your browser:": "अपने ब्राउज़र में खोज परिणाम खोलें:",
"Open in {{name}}": "{{name}} में खोलें",
"Book Fingerprint": "पुस्तक फिंगरप्रिंट"
"Book Fingerprint": "पुस्तक फिंगरप्रिंट",
"Search the web": "वेब पर खोजें",
"App deeplink (readest://)": "ऐप डीप लिंक (readest://)",
"Universal web link (https://)": "सार्वभौमिक वेब लिंक (https://)",
"Name cannot be empty.": "नाम खाली नहीं हो सकता।",
"Replaced {{count}} existing dictionary_one": "{{count}} मौजूदा शब्दकोश बदला गया",
"Replaced {{count}} existing dictionary_other": "{{count}} मौजूदा शब्दकोश बदले गए",
"Cancel Edit": "संपादन रद्द करें",
"Edit Dictionary": "शब्दकोश संपादित करें",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict बंडल .mdx फ़ाइलों का उपयोग करते हैं; साथ की .mdd और .css फ़ाइलें वैकल्पिक हैं।",
"Dictionary name": "शब्दकोश का नाम",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "यह ऑडियो यहाँ नहीं चलाया जा सकता — शब्दकोश पुराने प्रारूप का उपयोग करता है। Opus, MP3 या WAV ऑडियो वाला कोई आज़माएँ।"
}
@@ -418,7 +418,6 @@
"System Language": "Rendszernyelv",
"No translation available.": "Nem érhető el fordítás.",
"Translated by {{provider}}.": "Fordította: {{provider}}.",
"Error": "Hiba",
"Remove Bookmark": "Könyvjelző eltávolítása",
"Add Bookmark": "Könyvjelző hozzáadása",
"Books Content": "Könyvek tartalma",
@@ -1211,11 +1210,6 @@
"Failed to start stream": "Nem sikerült elindítani a streamet",
"No dictionaries enabled": "Nincs engedélyezett szótár",
"Enable a dictionary in Settings → Language → Dictionaries.": "Engedélyezz egy szótárt a Beállítások → Nyelv → Szótárak menüben.",
"No definitions found": "Nem található meghatározás",
"Search for {{word}} on the web.": "{{word}} keresése a weben.",
"Dictionary unsupported": "A szótár nem támogatott",
"This dictionary format is not supported yet.": "Ez a szótárformátum még nem támogatott.",
"Unable to load the word.": "A szó nem tölthető be.",
"Wiktionary": "Wikiszótár",
"Drag to reorder": "Húzd az átrendezéshez",
"Built-in": "Beépített",
@@ -1233,7 +1227,6 @@
"Import Dictionary": "Szótár importálása",
"No dictionaries available.": "Nincsenek elérhető szótárak.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "A StarDict csomagokhoz .ifo, .idx és .dict.dz fájlok szükségesek (.syn opcionális).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Az MDict csomagok .mdx fájlokat használnak; a kísérő .mdd fájlok opcionálisak.",
"Select all the bundle files together when importing.": "Importáláskor jelöld ki egyszerre a csomag összes fájlját.",
"Manage Dictionaries": "Szótárak kezelése",
"Select Dictionary Files": "Válassz szótárfájlokat",
@@ -1328,5 +1321,16 @@
"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}}",
"Book Fingerprint": "Könyv ujjlenyomata"
"Book Fingerprint": "Könyv ujjlenyomata",
"Search the web": "Keresés a weben",
"App deeplink (readest://)": "App-mélylink (readest://)",
"Universal web link (https://)": "Univerzális weblink (https://)",
"Name cannot be empty.": "A név nem lehet üres.",
"Replaced {{count}} existing dictionary_one": "{{count}} meglévő szótár cserélve",
"Replaced {{count}} existing dictionary_other": "{{count}} meglévő szótár cserélve",
"Cancel Edit": "Szerkesztés mégse",
"Edit Dictionary": "Szótár szerkesztése",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Az MDict csomagok .mdx fájlokat használnak; a kísérő .mdd és .css fájlok opcionálisak.",
"Dictionary name": "Szótár neve",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Ez a hang itt nem játszható le — a szótár elavult formátumot használ. Próbálj olyat, amely Opus, MP3 vagy WAV hangot tartalmaz."
}
@@ -826,7 +826,6 @@
"Failed to export the book.": "Gagal mengekspor buku.",
"Export Book": "Ekspor Buku",
"Whole word:": "Kata utuh:",
"Error": "Kesalahan",
"Date Published": "Tanggal terbit",
"Only for TTS:": "Hanya untuk TTS:",
"Uploaded": "Diunggah",
@@ -1194,11 +1193,6 @@
"Failed to start stream": "Gagal memulai stream",
"No dictionaries enabled": "Tidak ada kamus yang diaktifkan",
"Enable a dictionary in Settings → Language → Dictionaries.": "Aktifkan kamus di Pengaturan → Bahasa → Kamus.",
"No definitions found": "Tidak ada definisi yang ditemukan",
"Search for {{word}} on the web.": "Cari {{word}} di web.",
"Dictionary unsupported": "Kamus tidak didukung",
"This dictionary format is not supported yet.": "Format kamus ini belum didukung.",
"Unable to load the word.": "Tidak dapat memuat kata.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Seret untuk mengurutkan ulang",
"Built-in": "Bawaan",
@@ -1215,7 +1209,6 @@
"Import Dictionary": "Impor Kamus",
"No dictionaries available.": "Tidak ada kamus yang tersedia.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Paket StarDict memerlukan file .ifo, .idx, dan .dict.dz (.syn opsional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Paket MDict menggunakan file .mdx; file .mdd pendamping bersifat opsional.",
"Select all the bundle files together when importing.": "Pilih semua file paket sekaligus saat mengimpor.",
"Manage Dictionaries": "Kelola Kamus",
"Select Dictionary Files": "Pilih File Kamus",
@@ -1305,5 +1298,15 @@
"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}}",
"Book Fingerprint": "Sidik jari buku"
"Book Fingerprint": "Sidik jari buku",
"Search the web": "Cari di web",
"App deeplink (readest://)": "Tautan dalam aplikasi (readest://)",
"Universal web link (https://)": "Tautan web universal (https://)",
"Name cannot be empty.": "Nama tidak boleh kosong.",
"Replaced {{count}} existing dictionary_other": "{{count}} kamus yang ada digantikan",
"Cancel Edit": "Batalkan Edit",
"Edit Dictionary": "Edit Kamus",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Paket MDict menggunakan berkas .mdx; berkas .mdd dan .css pendamping bersifat opsional.",
"Dictionary name": "Nama kamus",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Audio ini tidak dapat diputar di sini — kamus menggunakan format usang. Coba kamus dengan audio Opus, MP3, atau WAV."
}
@@ -846,7 +846,6 @@
"Failed to export the book.": "Impossibile esportare il libro.",
"Export Book": "Esporta libro",
"Whole word:": "Parola intera:",
"Error": "Errore",
"Date Published": "Data di pubblicazione",
"Only for TTS:": "Solo per TTS:",
"Uploaded": "Caricato",
@@ -1228,11 +1227,6 @@
"Failed to start stream": "Avvio dello stream non riuscito",
"No dictionaries enabled": "Nessun dizionario abilitato",
"Enable a dictionary in Settings → Language → Dictionaries.": "Abilita un dizionario in Impostazioni → Lingua → Dizionari.",
"No definitions found": "Nessuna definizione trovata",
"Search for {{word}} on the web.": "Cerca {{word}} sul web.",
"Dictionary unsupported": "Dizionario non supportato",
"This dictionary format is not supported yet.": "Questo formato di dizionario non è ancora supportato.",
"Unable to load the word.": "Impossibile caricare la parola.",
"Wiktionary": "Wikizionario",
"Drag to reorder": "Trascina per riordinare",
"Built-in": "Integrato",
@@ -1251,7 +1245,6 @@
"Import Dictionary": "Importa dizionario",
"No dictionaries available.": "Nessun dizionario disponibile.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "I pacchetti StarDict richiedono file .ifo, .idx e .dict.dz (.syn opzionale).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "I pacchetti MDict usano file .mdx; i .mdd associati sono opzionali.",
"Select all the bundle files together when importing.": "Seleziona tutti i file del pacchetto insieme durante limportazione.",
"Manage Dictionaries": "Gestisci dizionari",
"Select Dictionary Files": "Seleziona file del dizionario",
@@ -1351,5 +1344,17 @@
"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}}",
"Book Fingerprint": "Impronta del libro"
"Book Fingerprint": "Impronta del libro",
"Search the web": "Cerca nel web",
"App deeplink (readest://)": "Deeplink app (readest://)",
"Universal web link (https://)": "Link web universale (https://)",
"Name cannot be empty.": "Il nome non può essere vuoto.",
"Replaced {{count}} existing dictionary_one": "Sostituito {{count}} dizionario esistente",
"Replaced {{count}} existing dictionary_many": "Sostituiti {{count}} dizionari esistenti",
"Replaced {{count}} existing dictionary_other": "Sostituiti {{count}} dizionari esistenti",
"Cancel Edit": "Annulla modifica",
"Edit Dictionary": "Modifica dizionario",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "I pacchetti MDict usano file .mdx; i file .mdd e .css di accompagnamento sono facoltativi.",
"Dictionary name": "Nome del dizionario",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Questo audio non può essere riprodotto qui — il dizionario usa un formato obsoleto. Prova un dizionario con audio Opus, MP3 o WAV."
}
@@ -826,7 +826,6 @@
"Failed to export the book.": "書籍のエクスポートに失敗しました。",
"Export Book": "書籍をエクスポート",
"Whole word:": "単語全体:",
"Error": "エラー",
"Date Published": "出版日",
"Only for TTS:": "TTSのみ:",
"Uploaded": "アップロード済み",
@@ -1194,11 +1193,6 @@
"Failed to start stream": "ストリームの開始に失敗しました",
"No dictionaries enabled": "有効な辞書がありません",
"Enable a dictionary in Settings → Language → Dictionaries.": "設定 → 言語 → 辞書 で辞書を有効にしてください。",
"No definitions found": "定義が見つかりません",
"Search for {{word}} on the web.": "ウェブで {{word}} を検索。",
"Dictionary unsupported": "辞書は未対応です",
"This dictionary format is not supported yet.": "この辞書形式はまだサポートされていません。",
"Unable to load the word.": "単語を読み込めませんでした。",
"Wiktionary": "ウィクショナリー",
"Drag to reorder": "ドラッグで並べ替え",
"Built-in": "組み込み",
@@ -1215,7 +1209,6 @@
"Import Dictionary": "辞書をインポート",
"No dictionaries available.": "利用可能な辞書はありません。",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict バンドルには .ifo、.idx、.dict.dz ファイルが必要です(.syn は任意)。",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict バンドルは .mdx ファイルを使用します。付随する .mdd ファイルは任意です。",
"Select all the bundle files together when importing.": "インポート時はバンドルのファイルをまとめて選択してください。",
"Manage Dictionaries": "辞書を管理",
"Select Dictionary Files": "辞書ファイルを選択",
@@ -1305,5 +1298,15 @@
"Use %WORD% where the looked-up word should appear.": "調べる単語が表示される場所に %WORD% を使用してください。",
"Open the search result in your browser:": "検索結果をブラウザで開く:",
"Open in {{name}}": "{{name}} で開く",
"Book Fingerprint": "書籍の指紋"
"Book Fingerprint": "書籍の指紋",
"Search the web": "ウェブで検索",
"App deeplink (readest://)": "アプリディープリンク (readest://)",
"Universal web link (https://)": "ユニバーサルウェブリンク (https://)",
"Name cannot be empty.": "名前を空にすることはできません。",
"Replaced {{count}} existing dictionary_other": "既存の辞書 {{count}} 件を置き換えました",
"Cancel Edit": "編集をキャンセル",
"Edit Dictionary": "辞書を編集",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict バンドルは .mdx ファイルを使用します。付属の .mdd および .css ファイルは任意です。",
"Dictionary name": "辞書名",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "この音声はここで再生できません — 辞書が古い形式を使用しています。Opus、MP3、または WAV 音声を使用する辞書を試してください。"
}
@@ -826,7 +826,6 @@
"Failed to export the book.": "책 내보내기에 실패했습니다.",
"Export Book": "책 내보내기",
"Whole word:": "전체 단어:",
"Error": "오류",
"Date Published": "출판일",
"Only for TTS:": "TTS 전용:",
"Uploaded": "업로드됨",
@@ -1194,11 +1193,6 @@
"Failed to start stream": "스트림을 시작할 수 없습니다",
"No dictionaries enabled": "활성화된 사전이 없습니다",
"Enable a dictionary in Settings → Language → Dictionaries.": "설정 → 언어 → 사전 에서 사전을 활성화하세요.",
"No definitions found": "정의를 찾을 수 없습니다",
"Search for {{word}} on the web.": "웹에서 {{word}} 검색.",
"Dictionary unsupported": "지원되지 않는 사전",
"This dictionary format is not supported yet.": "이 사전 형식은 아직 지원되지 않습니다.",
"Unable to load the word.": "단어를 불러올 수 없습니다.",
"Wiktionary": "위키낱말사전",
"Drag to reorder": "드래그하여 순서 변경",
"Built-in": "내장",
@@ -1215,7 +1209,6 @@
"Import Dictionary": "사전 가져오기",
"No dictionaries available.": "사용 가능한 사전이 없습니다.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict 번들은 .ifo, .idx, .dict.dz 파일이 필요합니다(.syn은 선택).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict 번들은 .mdx 파일을 사용하며, 동반되는 .mdd 파일은 선택입니다.",
"Select all the bundle files together when importing.": "가져올 때 번들의 모든 파일을 함께 선택하세요.",
"Manage Dictionaries": "사전 관리",
"Select Dictionary Files": "사전 파일 선택",
@@ -1305,5 +1298,15 @@
"Use %WORD% where the looked-up word should appear.": "검색어가 표시될 위치에 %WORD%를 사용하세요.",
"Open the search result in your browser:": "브라우저에서 검색 결과 열기:",
"Open in {{name}}": "{{name}}에서 열기",
"Book Fingerprint": "책 지문"
"Book Fingerprint": "책 지문",
"Search the web": "웹에서 검색",
"App deeplink (readest://)": "앱 딥링크 (readest://)",
"Universal web link (https://)": "범용 웹 링크 (https://)",
"Name cannot be empty.": "이름은 비워 둘 수 없습니다.",
"Replaced {{count}} existing dictionary_other": "기존 사전 {{count}}개를 교체했습니다",
"Cancel Edit": "편집 취소",
"Edit Dictionary": "사전 편집",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict 번들은 .mdx 파일을 사용합니다. 동반된 .mdd 및 .css 파일은 선택 사항입니다.",
"Dictionary name": "사전 이름",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "여기서는 이 오디오를 재생할 수 없습니다 — 사전이 오래된 형식을 사용합니다. Opus, MP3 또는 WAV 오디오 사전을 사용해 보세요."
}
@@ -826,7 +826,6 @@
"Failed to export the book.": "Gagal mengeksport buku.",
"Export Book": "Eksport Buku",
"Whole word:": "Perkataan penuh:",
"Error": "Ralat",
"Date Published": "Tarikh diterbitkan",
"Only for TTS:": "Hanya untuk TTS:",
"Uploaded": "Dimuat naik",
@@ -1194,11 +1193,6 @@
"Failed to start stream": "Gagal memulakan strim",
"No dictionaries enabled": "Tiada kamus diaktifkan",
"Enable a dictionary in Settings → Language → Dictionaries.": "Aktifkan kamus di Tetapan → Bahasa → Kamus.",
"No definitions found": "Tiada definisi ditemukan",
"Search for {{word}} on the web.": "Cari {{word}} di web.",
"Dictionary unsupported": "Kamus tidak disokong",
"This dictionary format is not supported yet.": "Format kamus ini belum disokong.",
"Unable to load the word.": "Tidak dapat memuatkan perkataan.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Seret untuk susun semula",
"Built-in": "Terbina dalam",
@@ -1215,7 +1209,6 @@
"Import Dictionary": "Import Kamus",
"No dictionaries available.": "Tiada kamus tersedia.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Bundle StarDict memerlukan fail .ifo, .idx dan .dict.dz (.syn pilihan).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Bundle MDict menggunakan fail .mdx; fail .mdd pengiring adalah pilihan.",
"Select all the bundle files together when importing.": "Pilih semua fail bundle sekali gus semasa mengimport.",
"Manage Dictionaries": "Urus Kamus",
"Select Dictionary Files": "Pilih Fail Kamus",
@@ -1305,5 +1298,15 @@
"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}}",
"Book Fingerprint": "Cap jari buku"
"Book Fingerprint": "Cap jari buku",
"Search the web": "Cari di web",
"App deeplink (readest://)": "Pautan dalam apl (readest://)",
"Universal web link (https://)": "Pautan web universal (https://)",
"Name cannot be empty.": "Nama tidak boleh kosong.",
"Replaced {{count}} existing dictionary_other": "Menggantikan {{count}} kamus sedia ada",
"Cancel Edit": "Batal Sunting",
"Edit Dictionary": "Sunting Kamus",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Bungkusan MDict menggunakan fail .mdx; fail .mdd dan .css yang menyertainya adalah pilihan.",
"Dictionary name": "Nama kamus",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Audio ini tidak boleh dimainkan di sini — kamus menggunakan format lapuk. Cuba kamus dengan audio Opus, MP3 atau WAV."
}
@@ -836,7 +836,6 @@
"Failed to export the book.": "Exporteren van het boek mislukt.",
"Export Book": "Boek exporteren",
"Whole word:": "Heel woord:",
"Error": "Fout",
"Date Published": "Publicatiedatum",
"Only for TTS:": "Alleen voor TTS:",
"Uploaded": "Geüpload",
@@ -1211,11 +1210,6 @@
"Failed to start stream": "Kan stream niet starten",
"No dictionaries enabled": "Geen woordenboeken ingeschakeld",
"Enable a dictionary in Settings → Language → Dictionaries.": "Schakel een woordenboek in via Instellingen → Taal → Woordenboeken.",
"No definitions found": "Geen definities gevonden",
"Search for {{word}} on the web.": "Zoek {{word}} op het web.",
"Dictionary unsupported": "Woordenboek niet ondersteund",
"This dictionary format is not supported yet.": "Dit woordenboekformaat wordt nog niet ondersteund.",
"Unable to load the word.": "Kan het woord niet laden.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Sleep om opnieuw te ordenen",
"Built-in": "Ingebouwd",
@@ -1233,7 +1227,6 @@
"Import Dictionary": "Woordenboek importeren",
"No dictionaries available.": "Geen woordenboeken beschikbaar.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict-bundels hebben .ifo-, .idx- en .dict.dz-bestanden nodig (.syn optioneel).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict-bundels gebruiken .mdx-bestanden; bijbehorende .mdd-bestanden zijn optioneel.",
"Select all the bundle files together when importing.": "Selecteer bij het importeren alle bundelbestanden tegelijk.",
"Manage Dictionaries": "Woordenboeken beheren",
"Select Dictionary Files": "Woordenboekbestanden kiezen",
@@ -1328,5 +1321,16 @@
"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}}",
"Book Fingerprint": "Vingerafdruk van boek"
"Book Fingerprint": "Vingerafdruk van boek",
"Search the web": "Zoeken op het web",
"App deeplink (readest://)": "App-deeplink (readest://)",
"Universal web link (https://)": "Universele weblink (https://)",
"Name cannot be empty.": "Naam mag niet leeg zijn.",
"Replaced {{count}} existing dictionary_one": "{{count}} bestaand woordenboek vervangen",
"Replaced {{count}} existing dictionary_other": "{{count}} bestaande woordenboeken vervangen",
"Cancel Edit": "Bewerken annuleren",
"Edit Dictionary": "Woordenboek bewerken",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict-bundels gebruiken .mdx-bestanden; bijbehorende .mdd- en .css-bestanden zijn optioneel.",
"Dictionary name": "Naam van woordenboek",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Deze audio kan hier niet worden afgespeeld — het woordenboek gebruikt een verouderd formaat. Probeer een woordenboek met Opus-, MP3- of WAV-audio."
}
@@ -856,7 +856,6 @@
"Failed to export the book.": "Nie udało się wyeksportować książki.",
"Export Book": "Eksportuj książkę",
"Whole word:": "Całe słowo:",
"Error": "Błąd",
"Date Published": "Data wydania",
"Only for TTS:": "Tylko dla TTS:",
"Uploaded": "Przesłano",
@@ -1245,11 +1244,6 @@
"Failed to start stream": "Nie udało się uruchomić strumienia",
"No dictionaries enabled": "Brak włączonych słowników",
"Enable a dictionary in Settings → Language → Dictionaries.": "Włącz słownik w Ustawienia → Język → Słowniki.",
"No definitions found": "Nie znaleziono definicji",
"Search for {{word}} on the web.": "Szukaj {{word}} w sieci.",
"Dictionary unsupported": "Słownik nieobsługiwany",
"This dictionary format is not supported yet.": "Ten format słownika nie jest jeszcze obsługiwany.",
"Unable to load the word.": "Nie można załadować słowa.",
"Wiktionary": "Wikisłownik",
"Drag to reorder": "Przeciągnij, aby zmienić kolejność",
"Built-in": "Wbudowany",
@@ -1269,7 +1263,6 @@
"Import Dictionary": "Importuj słownik",
"No dictionaries available.": "Brak dostępnych słowników.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Pakiety StarDict wymagają plików .ifo, .idx i .dict.dz (.syn opcjonalny).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Pakiety MDict używają plików .mdx; towarzyszące .mdd są opcjonalne.",
"Select all the bundle files together when importing.": "Podczas importu wybierz wszystkie pliki pakietu jednocześnie.",
"Manage Dictionaries": "Zarządzaj słownikami",
"Select Dictionary Files": "Wybierz pliki słownika",
@@ -1374,5 +1367,18 @@
"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}}",
"Book Fingerprint": "Odcisk książki"
"Book Fingerprint": "Odcisk książki",
"Search the web": "Szukaj w sieci",
"App deeplink (readest://)": "Głęboki link aplikacji (readest://)",
"Universal web link (https://)": "Uniwersalny link sieciowy (https://)",
"Name cannot be empty.": "Nazwa nie może być pusta.",
"Replaced {{count}} existing dictionary_one": "Zastąpiono {{count}} istniejący słownik",
"Replaced {{count}} existing dictionary_few": "Zastąpiono {{count}} istniejące słowniki",
"Replaced {{count}} existing dictionary_many": "Zastąpiono {{count}} istniejących słowników",
"Replaced {{count}} existing dictionary_other": "Zastąpiono {{count}} istniejące słowniki",
"Cancel Edit": "Anuluj edycję",
"Edit Dictionary": "Edytuj słownik",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Pakiety MDict używają plików .mdx; towarzyszące pliki .mdd i .css są opcjonalne.",
"Dictionary name": "Nazwa słownika",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Tego dźwięku nie można odtworzyć tutaj — słownik używa przestarzałego formatu. Spróbuj słownika z dźwiękiem Opus, MP3 lub WAV."
}
@@ -382,12 +382,6 @@
"No dictionaries enabled": "Nenhum dicionário ativado",
"Enable a dictionary in Settings → Language → Dictionaries.": "Ative um dicionário em Configurações → Idioma → Dicionários.",
"Manage Dictionaries": "Gerenciar dicionários",
"No definitions found": "Nenhuma definição encontrada",
"Search for {{word}} on the web.": "Pesquisar {{word}} na web.",
"Dictionary unsupported": "Dicionário não suportado",
"This dictionary format is not supported yet.": "Este formato de dicionário ainda não é suportado.",
"Error": "Erro",
"Unable to load the word.": "Não foi possível carregar a palavra.",
"Exported from Readest": "Exportado do Readest",
"Highlights & Annotations": "Destaques e anotações",
"Note:": "Nota:",
@@ -1112,7 +1106,6 @@
"No dictionaries available.": "Nenhum dicionário disponível.",
"Tips": "Dicas",
"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.",
"DICT bundles need a .index file and a .dict.dz file.": "Pacotes DICT precisam de um arquivo .index e um arquivo .dict.dz.",
"Slob bundles need a .slob file.": "Pacotes Slob precisam de um arquivo .slob.",
"Select all the bundle files together when importing.": "Selecione todos os arquivos do pacote juntos ao importar.",
@@ -1351,5 +1344,17 @@
"Sunset": "Pôr do sol",
"Reveal in Finder": "Mostrar no Finder",
"Reveal in File Explorer": "Mostrar no Explorador de Arquivos",
"Reveal in Folder": "Mostrar na pasta"
"Reveal in Folder": "Mostrar na pasta",
"Search the web": "Pesquisar na web",
"App deeplink (readest://)": "Deeplink do aplicativo (readest://)",
"Universal web link (https://)": "Link web universal (https://)",
"Name cannot be empty.": "O nome não pode estar vazio.",
"Replaced {{count}} existing dictionary_one": "{{count}} dicionário existente substituído",
"Replaced {{count}} existing dictionary_many": "{{count}} dicionários existentes substituídos",
"Replaced {{count}} existing dictionary_other": "{{count}} dicionários existentes substituídos",
"Cancel Edit": "Cancelar edição",
"Edit Dictionary": "Editar dicionário",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Os pacotes MDict usam arquivos .mdx; os arquivos .mdd e .css que os acompanham são opcionais.",
"Dictionary name": "Nome do dicionário",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este áudio não pode ser reproduzido aqui — o dicionário usa um formato obsoleto. Tente um dicionário com áudio Opus, MP3 ou WAV."
}
@@ -846,7 +846,6 @@
"Failed to export the book.": "Falha ao exportar o livro.",
"Export Book": "Exportar livro",
"Whole word:": "Palavra inteira:",
"Error": "Erro",
"Date Published": "Data de publicação",
"Only for TTS:": "Apenas para TTS:",
"Uploaded": "Enviado",
@@ -1228,11 +1227,6 @@
"Failed to start stream": "Falha ao iniciar o streaming",
"No dictionaries enabled": "Nenhum dicionário ativado",
"Enable a dictionary in Settings → Language → Dictionaries.": "Ative um dicionário em Configurações → Idioma → Dicionários.",
"No definitions found": "Nenhuma definição encontrada",
"Search for {{word}} on the web.": "Pesquisar {{word}} na web.",
"Dictionary unsupported": "Dicionário não suportado",
"This dictionary format is not supported yet.": "Este formato de dicionário ainda não é suportado.",
"Unable to load the word.": "Não foi possível carregar a palavra.",
"Wiktionary": "Wikcionário",
"Drag to reorder": "Arraste para reordenar",
"Built-in": "Integrado",
@@ -1251,7 +1245,6 @@
"Import Dictionary": "Importar dicionário",
"No dictionaries available.": "Nenhum dicionário disponível.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Pacotes StarDict precisam dos arquivos .ifo, .idx e .dict.dz (.syn opcional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Pacotes MDict usam arquivos .mdx; arquivos .mdd companheiros são opcionais.",
"Select all the bundle files together when importing.": "Selecione todos os arquivos do pacote juntos ao importar.",
"Manage Dictionaries": "Gerenciar dicionários",
"Select Dictionary Files": "Selecionar arquivos do dicionário",
@@ -1351,5 +1344,17 @@
"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}}",
"Book Fingerprint": "Impressão digital do livro"
"Book Fingerprint": "Impressão digital do livro",
"Search the web": "Pesquisar na web",
"App deeplink (readest://)": "Deeplink da aplicação (readest://)",
"Universal web link (https://)": "Ligação web universal (https://)",
"Name cannot be empty.": "O nome não pode estar vazio.",
"Replaced {{count}} existing dictionary_one": "{{count}} dicionário existente substituído",
"Replaced {{count}} existing dictionary_many": "{{count}} dicionários existentes substituídos",
"Replaced {{count}} existing dictionary_other": "{{count}} dicionários existentes substituídos",
"Cancel Edit": "Cancelar edição",
"Edit Dictionary": "Editar dicionário",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Os pacotes MDict usam ficheiros .mdx; os ficheiros .mdd e .css que os acompanham são opcionais.",
"Dictionary name": "Nome do dicionário",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este áudio não pode ser reproduzido aqui — o dicionário usa um formato obsoleto. Experimenta um dicionário com áudio Opus, MP3 ou WAV."
}
@@ -420,7 +420,6 @@
"System Language": "Limba sistemului",
"No translation available.": "Nicio traducere disponibilă.",
"Translated by {{provider}}.": "Tradus de {{provider}}.",
"Error": "Eroare",
"Remove Bookmark": "Eliminați marcajul",
"Add Bookmark": "Adăugați marcaj",
"Books Content": "Conținutul cărților",
@@ -1228,11 +1227,6 @@
"Failed to start stream": "Eroare la pornirea fluxului",
"No dictionaries enabled": "Niciun dicționar activat",
"Enable a dictionary in Settings → Language → Dictionaries.": "Activează un dicționar din Setări → Limbă → Dicționare.",
"No definitions found": "Nu s-au găsit definiții",
"Search for {{word}} on the web.": "Caută {{word}} pe web.",
"Dictionary unsupported": "Dicționarul nu este acceptat",
"This dictionary format is not supported yet.": "Acest format de dicționar nu este încă acceptat.",
"Unable to load the word.": "Nu s-a putut încărca cuvântul.",
"Wiktionary": "Wikționar",
"Drag to reorder": "Trage pentru a reordona",
"Built-in": "Încorporat",
@@ -1251,7 +1245,6 @@
"Import Dictionary": "Importă dicționar",
"No dictionaries available.": "Niciun dicționar disponibil.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Pachetele StarDict au nevoie de fișierele .ifo, .idx și .dict.dz (.syn opțional).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Pachetele MDict folosesc fișiere .mdx; fișierele .mdd însoțitoare sunt opționale.",
"Select all the bundle files together when importing.": "Selectează împreună toate fișierele pachetului la import.",
"Manage Dictionaries": "Gestionează dicționarele",
"Select Dictionary Files": "Selectează fișierele dicționarului",
@@ -1351,5 +1344,17 @@
"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}}",
"Book Fingerprint": "Amprenta cărții"
"Book Fingerprint": "Amprenta cărții",
"Search the web": "Caută pe web",
"App deeplink (readest://)": "Deeplink al aplicației (readest://)",
"Universal web link (https://)": "Link web universal (https://)",
"Name cannot be empty.": "Numele nu poate fi gol.",
"Replaced {{count}} existing dictionary_one": "A fost înlocuit {{count}} dicționar existent",
"Replaced {{count}} existing dictionary_few": "Au fost înlocuite {{count}} dicționare existente",
"Replaced {{count}} existing dictionary_other": "Au fost înlocuite {{count}} de dicționare existente",
"Cancel Edit": "Anulează editarea",
"Edit Dictionary": "Editează dicționarul",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Pachetele MDict folosesc fișiere .mdx; fișierele .mdd și .css însoțitoare sunt opționale.",
"Dictionary name": "Numele dicționarului",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Acest audio nu poate fi redat aici — dicționarul folosește un format învechit. Încearcă unul cu audio Opus, MP3 sau WAV."
}
@@ -856,7 +856,6 @@
"Failed to export the book.": "Не удалось экспортировать книгу.",
"Export Book": "Экспорт книги",
"Whole word:": "Слово целиком:",
"Error": "Ошибка",
"Date Published": "Дата публикации",
"Only for TTS:": "Только для TTS:",
"Uploaded": "Загружено",
@@ -1245,11 +1244,6 @@
"Failed to start stream": "Не удалось запустить поток",
"No dictionaries enabled": "Нет включённых словарей",
"Enable a dictionary in Settings → Language → Dictionaries.": "Включите словарь в Настройки → Язык → Словари.",
"No definitions found": "Определения не найдены",
"Search for {{word}} on the web.": "Искать {{word}} в интернете.",
"Dictionary unsupported": "Словарь не поддерживается",
"This dictionary format is not supported yet.": "Этот формат словаря пока не поддерживается.",
"Unable to load the word.": "Не удалось загрузить слово.",
"Wiktionary": "Викисловарь",
"Drag to reorder": "Перетащите для изменения порядка",
"Built-in": "Встроенный",
@@ -1269,7 +1263,6 @@
"Import Dictionary": "Импорт словаря",
"No dictionaries available.": "Нет доступных словарей.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Пакеты StarDict требуют файлов .ifo, .idx и .dict.dz (.syn — опционально).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Пакеты MDict используют файлы .mdx; сопутствующие .mdd — опционально.",
"Select all the bundle files together when importing.": "При импорте выбирайте все файлы пакета вместе.",
"Manage Dictionaries": "Управление словарями",
"Select Dictionary Files": "Выберите файлы словаря",
@@ -1374,5 +1367,18 @@
"Use %WORD% where the looked-up word should appear.": "Используйте %WORD% там, где должно появиться искомое слово.",
"Open the search result in your browser:": "Открыть результат поиска в браузере:",
"Open in {{name}}": "Открыть в {{name}}",
"Book Fingerprint": "Отпечаток книги"
"Book Fingerprint": "Отпечаток книги",
"Search the web": "Поиск в интернете",
"App deeplink (readest://)": "Глубокая ссылка приложения (readest://)",
"Universal web link (https://)": "Универсальная веб-ссылка (https://)",
"Name cannot be empty.": "Имя не может быть пустым.",
"Replaced {{count}} existing dictionary_one": "Заменён {{count}} существующий словарь",
"Replaced {{count}} existing dictionary_few": "Заменено {{count}} существующих словаря",
"Replaced {{count}} existing dictionary_many": "Заменено {{count}} существующих словарей",
"Replaced {{count}} existing dictionary_other": "Заменено {{count}} существующих словарей",
"Cancel Edit": "Отменить редактирование",
"Edit Dictionary": "Редактировать словарь",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Пакеты MDict используют файлы .mdx; сопутствующие файлы .mdd и .css необязательны.",
"Dictionary name": "Название словаря",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Этот звук невозможно воспроизвести здесь — словарь использует устаревший формат. Попробуйте словарь с аудио Opus, MP3 или WAV."
}
@@ -836,7 +836,6 @@
"Failed to export the book.": "පොත අපනයනය කිරීමට අසමත් විය.",
"Export Book": "පොත අපනයනය කරන්න",
"Whole word:": "සම්පූර්ණ වචනය:",
"Error": "දෝෂය",
"Date Published": "ප්‍රකාශන දිනය",
"Only for TTS:": "TTS සඳහා පමණක්:",
"Uploaded": "උඩුගත කරන ලදී",
@@ -1211,11 +1210,6 @@
"Failed to start stream": "ස්ට්‍රීම් ආරම්භ කිරීම අසාර්ථකයි",
"No dictionaries enabled": "ශබ්දකෝෂ සක්‍රීය කර නැත",
"Enable a dictionary in Settings → Language → Dictionaries.": "සැකසීම් → භාෂාව → ශබ්දකෝෂ වෙත ගොස් ශබ්දකෝෂයක් සක්‍රීය කරන්න.",
"No definitions found": "අර්ථදැක්වීම් හමු නොවීය",
"Search for {{word}} on the web.": "වෙබයේ {{word}} සොයන්න.",
"Dictionary unsupported": "ශබ්දකෝෂයට සහාය නොදක්වයි",
"This dictionary format is not supported yet.": "මෙම ශබ්දකෝෂ ආකෘතියට තවම සහාය නොදක්වයි.",
"Unable to load the word.": "වචනය පූරණය කළ නොහැකි විය.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "නැවත සකස් කිරීමට අදින්න",
"Built-in": "සාමාන්‍ය",
@@ -1233,7 +1227,6 @@
"Import Dictionary": "ශබ්දකෝෂයක් ආයාත කරන්න",
"No dictionaries available.": "ලබා ගත හැකි ශබ්දකෝෂ නැත.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict බණ්ඩලවලට .ifo, .idx සහ .dict.dz ගොනු අවශ්‍යය (.syn විකල්ප).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict බණ්ඩල .mdx ගොනු භාවිතා කරයි; සහායක .mdd ගොනු විකල්ප වේ.",
"Select all the bundle files together when importing.": "ආයාත කරන විට බණ්ඩලේ සියලු ගොනු එකවර තෝරන්න.",
"Manage Dictionaries": "ශබ්දකෝෂ කළමනාකරණය",
"Select Dictionary Files": "ශබ්දකෝෂ ගොනු තෝරන්න",
@@ -1328,5 +1321,16 @@
"Use %WORD% where the looked-up word should appear.": "සොයන වචනය දිස්විය යුතු තැන %WORD% භාවිතා කරන්න.",
"Open the search result in your browser:": "ඔබේ බ්‍රවුසරයේ සෙවුම් ප්‍රතිඵලය විවෘත කරන්න:",
"Open in {{name}}": "{{name}} වල විවෘත කරන්න",
"Book Fingerprint": "පොතේ ඇඟිලි සලකුණ"
"Book Fingerprint": "පොතේ ඇඟිලි සලකුණ",
"Search the web": "වෙබයේ සොයන්න",
"App deeplink (readest://)": "යෙදුම් ගැඹුරු සබැඳිය (readest://)",
"Universal web link (https://)": "සර්වත්‍ර වෙබ් සබැඳිය (https://)",
"Name cannot be empty.": "නම හිස් විය නොහැක.",
"Replaced {{count}} existing dictionary_one": "පවතින ශබ්දකෝෂ {{count}}ක් ප්‍රතිස්ථාපනය කරන ලදී",
"Replaced {{count}} existing dictionary_other": "පවතින ශබ්දකෝෂ {{count}}ක් ප්‍රතිස්ථාපනය කරන ලදී",
"Cancel Edit": "සංස්කරණය අවලංගු කරන්න",
"Edit Dictionary": "ශබ්දකෝෂය සංස්කරණය කරන්න",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict පැකේජ .mdx ගොනු භාවිතා කරයි; සහායක .mdd සහ .css ගොනු විකල්පීය වේ.",
"Dictionary name": "ශබ්දකෝෂයේ නම",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "මෙම ශබ්දය මෙහි වාදනය කළ නොහැක — ශබ්දකෝෂය යල් පැන ගිය ආකෘතියක් භාවිතා කරයි. Opus, MP3 හෝ WAV ශබ්ද සහිත එකක් උත්සාහ කරන්න."
}
@@ -397,7 +397,6 @@
"System Language": "Sistemski jezik",
"No translation available.": "Prevod ni na voljo.",
"Translated by {{provider}}.": "Prevedeno s {{provider}}.",
"Error": "Napaka",
"Remove Bookmark": "Odstrani zaznamek",
"Add Bookmark": "Dodaj zaznamek",
"Books Content": "Vsebina knjig",
@@ -1245,11 +1244,6 @@
"Failed to start stream": "Pretoka ni bilo mogoče zagnati",
"No dictionaries enabled": "Ni omogočenih slovarjev",
"Enable a dictionary in Settings → Language → Dictionaries.": "Omogoči slovar v Nastavitve → Jezik → Slovarji.",
"No definitions found": "Ni najdenih razlag",
"Search for {{word}} on the web.": "Poišči {{word}} v spletu.",
"Dictionary unsupported": "Slovar ni podprt",
"This dictionary format is not supported yet.": "Ta oblika slovarja še ni podprta.",
"Unable to load the word.": "Besede ni mogoče naložiti.",
"Wiktionary": "Wikislovar",
"Drag to reorder": "Povleci za prerazporeditev",
"Built-in": "Vgrajeno",
@@ -1269,7 +1263,6 @@
"Import Dictionary": "Uvozi slovar",
"No dictionaries available.": "Ni razpoložljivih slovarjev.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Svežnji StarDict potrebujejo datoteke .ifo, .idx in .dict.dz (.syn neobvezno).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Svežnji MDict uporabljajo datoteke .mdx; spremljajoče .mdd so neobvezne.",
"Select all the bundle files together when importing.": "Pri uvozu izberi vse datoteke svežnja skupaj.",
"Manage Dictionaries": "Upravljanje slovarjev",
"Select Dictionary Files": "Izberi datoteke slovarja",
@@ -1374,5 +1367,18 @@
"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}}",
"Book Fingerprint": "Prstni odtis knjige"
"Book Fingerprint": "Prstni odtis knjige",
"Search the web": "Iskanje po spletu",
"App deeplink (readest://)": "Globoka povezava aplikacije (readest://)",
"Universal web link (https://)": "Univerzalna spletna povezava (https://)",
"Name cannot be empty.": "Ime ne sme biti prazno.",
"Replaced {{count}} existing dictionary_one": "Zamenjan {{count}} obstoječi slovar",
"Replaced {{count}} existing dictionary_two": "Zamenjana {{count}} obstoječa slovarja",
"Replaced {{count}} existing dictionary_few": "Zamenjani {{count}} obstoječi slovarji",
"Replaced {{count}} existing dictionary_other": "Zamenjanih {{count}} obstoječih slovarjev",
"Cancel Edit": "Prekliči urejanje",
"Edit Dictionary": "Uredi slovar",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Paketi MDict uporabljajo datoteke .mdx; spremne datoteke .mdd in .css so neobvezne.",
"Dictionary name": "Ime slovarja",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Tega zvoka tukaj ni mogoče predvajati — slovar uporablja zastarelo obliko. Poskusite slovar z zvokom Opus, MP3 ali WAV."
}
@@ -836,7 +836,6 @@
"Failed to export the book.": "Det gick inte att exportera boken.",
"Export Book": "Exportera bok",
"Whole word:": "Hela ordet:",
"Error": "Fel",
"Date Published": "Publiceringsdatum",
"Only for TTS:": "Endast för TTS:",
"Uploaded": "Uppladdad",
@@ -1211,11 +1210,6 @@
"Failed to start stream": "Det gick inte att starta strömmen",
"No dictionaries enabled": "Inga ordböcker aktiverade",
"Enable a dictionary in Settings → Language → Dictionaries.": "Aktivera en ordbok i Inställningar → Språk → Ordböcker.",
"No definitions found": "Inga definitioner hittades",
"Search for {{word}} on the web.": "Sök efter {{word}} på webben.",
"Dictionary unsupported": "Ordbok stöds inte",
"This dictionary format is not supported yet.": "Det här ordboksformatet stöds inte än.",
"Unable to load the word.": "Det gick inte att läsa in ordet.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Dra för att ändra ordning",
"Built-in": "Inbyggt",
@@ -1233,7 +1227,6 @@
"Import Dictionary": "Importera ordbok",
"No dictionaries available.": "Inga ordböcker tillgängliga.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict-paket kräver .ifo-, .idx- och .dict.dz-filer (.syn valfri).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict-paket använder .mdx-filer; tillhörande .mdd-filer är valfria.",
"Select all the bundle files together when importing.": "Markera alla paketfiler tillsammans när du importerar.",
"Manage Dictionaries": "Hantera ordböcker",
"Select Dictionary Files": "Välj ordboksfiler",
@@ -1328,5 +1321,16 @@
"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}}",
"Book Fingerprint": "Bokens fingeravtryck"
"Book Fingerprint": "Bokens fingeravtryck",
"Search the web": "Sök på webben",
"App deeplink (readest://)": "App-djuplänk (readest://)",
"Universal web link (https://)": "Universell webblänk (https://)",
"Name cannot be empty.": "Namnet får inte vara tomt.",
"Replaced {{count}} existing dictionary_one": "{{count}} befintlig ordbok ersatt",
"Replaced {{count}} existing dictionary_other": "{{count}} befintliga ordböcker ersatta",
"Cancel Edit": "Avbryt redigering",
"Edit Dictionary": "Redigera ordbok",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict-paket använder .mdx-filer; medföljande .mdd- och .css-filer är valfria.",
"Dictionary name": "Ordbokens namn",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Det här ljudet kan inte spelas upp här — ordboken använder ett föråldrat format. Prova en ordbok med Opus-, MP3- eller WAV-ljud."
}
@@ -836,7 +836,6 @@
"Failed to export the book.": "புத்தகத்தை ஏற்றுமதி செய்ய இயலவில்லை.",
"Export Book": "புத்தகத்தை ஏற்றுமதி செய்",
"Whole word:": "முழு சொல்:",
"Error": "பிழை",
"Date Published": "வெளியீட்டு தேதி",
"Only for TTS:": "TTS க்கு மட்டும்:",
"Uploaded": "பதிவேற்றப்பட்டது",
@@ -1211,11 +1210,6 @@
"Failed to start stream": "ஸ்ட்ரீமைத் தொடங்க முடியவில்லை",
"No dictionaries enabled": "எந்த அகராதியும் இயக்கப்படவில்லை",
"Enable a dictionary in Settings → Language → Dictionaries.": "அமைப்புகள் → மொழி → அகராதிகள் என்பதில் ஒரு அகராதியை இயக்கவும்.",
"No definitions found": "வரையறைகள் எதுவும் கிடைக்கவில்லை",
"Search for {{word}} on the web.": "வலையில் {{word}} ஐத் தேடவும்.",
"Dictionary unsupported": "அகராதி ஆதரிக்கப்படவில்லை",
"This dictionary format is not supported yet.": "இந்த அகராதி வடிவம் இன்னும் ஆதரிக்கப்படவில்லை.",
"Unable to load the word.": "சொல்லை ஏற்ற முடியவில்லை.",
"Wiktionary": "விக்சனரி",
"Drag to reorder": "மறு வரிசைப்படுத்த இழுக்கவும்",
"Built-in": "உள்ளமைந்த",
@@ -1233,7 +1227,6 @@
"Import Dictionary": "அகராதியை இறக்குமதி செய்",
"No dictionaries available.": "அகராதிகள் கிடைக்கவில்லை.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict தொகுப்புகளுக்கு .ifo, .idx, .dict.dz கோப்புகள் தேவை (.syn விருப்பம்).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict தொகுப்புகள் .mdx கோப்புகளைப் பயன்படுத்தும்; தொடர்புடைய .mdd கோப்புகள் விருப்பம்.",
"Select all the bundle files together when importing.": "இறக்குமதி செய்யும்போது தொகுப்பின் அனைத்துக் கோப்புகளையும் ஒரே நேரத்தில் தேர்ந்தெடுக்கவும்.",
"Manage Dictionaries": "அகராதிகளை நிர்வகி",
"Select Dictionary Files": "அகராதிக் கோப்புகளைத் தேர்ந்தெடு",
@@ -1328,5 +1321,16 @@
"Use %WORD% where the looked-up word should appear.": "தேடப்பட்ட சொல் தோன்ற வேண்டிய இடத்தில் %WORD% ஐ பயன்படுத்தவும்.",
"Open the search result in your browser:": "உங்கள் உலாவியில் தேடல் முடிவைத் திற:",
"Open in {{name}}": "{{name}} இல் திற",
"Book Fingerprint": "புத்தக கைரேகை"
"Book Fingerprint": "புத்தக கைரேகை",
"Search the web": "வலையில் தேடுக",
"App deeplink (readest://)": "பயன்பாட்டு ஆழ் இணைப்பு (readest://)",
"Universal web link (https://)": "அனைத்துக்கும் பொதுவான வலை இணைப்பு (https://)",
"Name cannot be empty.": "பெயரை வெறுமையாக வைக்க இயலாது.",
"Replaced {{count}} existing dictionary_one": "ஏற்கனவே உள்ள {{count}} அகராதி மாற்றப்பட்டது",
"Replaced {{count}} existing dictionary_other": "ஏற்கனவே உள்ள {{count}} அகராதிகள் மாற்றப்பட்டன",
"Cancel Edit": "திருத்துதலை ரத்து செய்",
"Edit Dictionary": "அகராதியைத் திருத்து",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict தொகுப்புகள் .mdx கோப்புகளைப் பயன்படுத்துகின்றன; இணைந்த .mdd மற்றும் .css கோப்புகள் விருப்பமானவை.",
"Dictionary name": "அகராதியின் பெயர்",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "இந்த ஒலியை இங்கே இயக்க இயலவில்லை — அகராதி காலாவதியான வடிவத்தைப் பயன்படுத்துகிறது. Opus, MP3 அல்லது WAV ஒலி உள்ள ஒன்றை முயற்சிக்கவும்."
}
@@ -826,7 +826,6 @@
"Failed to export the book.": "ส่งออกหนังสือล้มเหลว",
"Export Book": "ส่งออกหนังสือ",
"Whole word:": "คำทั้งคำ:",
"Error": "ข้อผิดพลาด",
"Date Published": "วันที่เผยแพร่",
"Only for TTS:": "สำหรับ TTS เท่านั้น:",
"Uploaded": "อัปโหลดแล้ว",
@@ -1194,11 +1193,6 @@
"Failed to start stream": "ไม่สามารถเริ่มสตรีมได้",
"No dictionaries enabled": "ไม่มีพจนานุกรมที่เปิดใช้งาน",
"Enable a dictionary in Settings → Language → Dictionaries.": "เปิดใช้พจนานุกรมที่ ตั้งค่า → ภาษา → พจนานุกรม",
"No definitions found": "ไม่พบคำจำกัดความ",
"Search for {{word}} on the web.": "ค้นหา {{word}} บนเว็บ",
"Dictionary unsupported": "ไม่รองรับพจนานุกรมนี้",
"This dictionary format is not supported yet.": "ยังไม่รองรับรูปแบบพจนานุกรมนี้",
"Unable to load the word.": "ไม่สามารถโหลดคำได้",
"Wiktionary": "วิกิพจนานุกรม",
"Drag to reorder": "ลากเพื่อจัดลำดับใหม่",
"Built-in": "ในตัว",
@@ -1215,7 +1209,6 @@
"Import Dictionary": "นำเข้าพจนานุกรม",
"No dictionaries available.": "ไม่มีพจนานุกรม",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "บันเดิล StarDict ต้องมีไฟล์ .ifo, .idx และ .dict.dz (.syn ไม่บังคับ)",
"MDict bundles use .mdx files; companion .mdd files are optional.": "บันเดิล MDict ใช้ไฟล์ .mdx ส่วนไฟล์ .mdd ที่มาด้วยเป็นทางเลือก",
"Select all the bundle files together when importing.": "เลือกไฟล์ทั้งหมดของบันเดิลพร้อมกันเมื่อทำการนำเข้า",
"Manage Dictionaries": "จัดการพจนานุกรม",
"Select Dictionary Files": "เลือกไฟล์พจนานุกรม",
@@ -1305,5 +1298,15 @@
"Use %WORD% where the looked-up word should appear.": "ใช้ %WORD% ในตำแหน่งที่คำค้นหาควรปรากฏ",
"Open the search result in your browser:": "เปิดผลลัพธ์การค้นหาในเบราว์เซอร์ของคุณ:",
"Open in {{name}}": "เปิดใน {{name}}",
"Book Fingerprint": "ลายนิ้วมือหนังสือ"
"Book Fingerprint": "ลายนิ้วมือหนังสือ",
"Search the web": "ค้นหาบนเว็บ",
"App deeplink (readest://)": "ดีปลิงก์ของแอป (readest://)",
"Universal web link (https://)": "ลิงก์เว็บสากล (https://)",
"Name cannot be empty.": "ชื่อต้องไม่ว่างเปล่า",
"Replaced {{count}} existing dictionary_other": "แทนที่พจนานุกรมเดิม {{count}} รายการแล้ว",
"Cancel Edit": "ยกเลิกการแก้ไข",
"Edit Dictionary": "แก้ไขพจนานุกรม",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "ชุด MDict ใช้ไฟล์ .mdx ส่วนไฟล์ .mdd และ .css ที่มาด้วยกันเป็นทางเลือก",
"Dictionary name": "ชื่อพจนานุกรม",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "ไม่สามารถเล่นเสียงนี้ที่นี่ได้ — พจนานุกรมใช้รูปแบบที่ล้าสมัย ลองใช้พจนานุกรมที่มีเสียง Opus, MP3 หรือ WAV"
}
@@ -836,7 +836,6 @@
"Failed to export the book.": "Kitap dışa aktarılamadı.",
"Export Book": "Kitabı Dışa Aktar",
"Whole word:": "Tam kelime:",
"Error": "Hata",
"Date Published": "Yayın tarihi",
"Only for TTS:": "Sadece TTS için:",
"Uploaded": "Yüklendi",
@@ -1211,11 +1210,6 @@
"Failed to start stream": "Akış başlatılamadı",
"No dictionaries enabled": "Etkin sözlük yok",
"Enable a dictionary in Settings → Language → Dictionaries.": "Ayarlar → Dil → Sözlükler kısmından bir sözlük etkinleştirin.",
"No definitions found": "Tanım bulunamadı",
"Search for {{word}} on the web.": "Webde {{word}} ara.",
"Dictionary unsupported": "Sözlük desteklenmiyor",
"This dictionary format is not supported yet.": "Bu sözlük biçimi henüz desteklenmiyor.",
"Unable to load the word.": "Sözcük yüklenemedi.",
"Wiktionary": "Vikisözlük",
"Drag to reorder": "Yeniden sıralamak için sürükleyin",
"Built-in": "Yerleşik",
@@ -1233,7 +1227,6 @@
"Import Dictionary": "Sözlük içe aktar",
"No dictionaries available.": "Kullanılabilir sözlük yok.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict paketleri .ifo, .idx ve .dict.dz dosyalarını gerektirir (.syn isteğe bağlı).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict paketleri .mdx dosyalarını kullanır; eşlik eden .mdd dosyaları isteğe bağlıdır.",
"Select all the bundle files together when importing.": "İçe aktarırken paketin tüm dosyalarını birlikte seçin.",
"Manage Dictionaries": "Sözlükleri yönet",
"Select Dictionary Files": "Sözlük dosyalarını seç",
@@ -1328,5 +1321,16 @@
"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ç",
"Book Fingerprint": "Kitap parmak izi"
"Book Fingerprint": "Kitap parmak izi",
"Search the web": "Webde ara",
"App deeplink (readest://)": "Uygulama derin bağlantısı (readest://)",
"Universal web link (https://)": "Evrensel web bağlantısı (https://)",
"Name cannot be empty.": "Ad boş olamaz.",
"Replaced {{count}} existing dictionary_one": "{{count}} mevcut sözlük değiştirildi",
"Replaced {{count}} existing dictionary_other": "{{count}} mevcut sözlük değiştirildi",
"Cancel Edit": "Düzenlemeyi iptal et",
"Edit Dictionary": "Sözlüğü düzenle",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict paketleri .mdx dosyalarını kullanır; eşlik eden .mdd ve .css dosyaları isteğe bağlıdır.",
"Dictionary name": "Sözlük adı",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Bu ses burada oynatılamıyor — sözlük eski bir biçim kullanıyor. Opus, MP3 veya WAV sesi olan bir sözlük deneyin."
}
@@ -856,7 +856,6 @@
"Failed to export the book.": "Не вдалося експортувати книгу.",
"Export Book": "Експортувати книгу",
"Whole word:": "Ціле слово:",
"Error": "Помилка",
"Date Published": "Дата публікації",
"Only for TTS:": "Тільки для TTS:",
"Uploaded": "Завантажено",
@@ -1245,11 +1244,6 @@
"Failed to start stream": "Не вдалося запустити потік",
"No dictionaries enabled": "Немає увімкнених словників",
"Enable a dictionary in Settings → Language → Dictionaries.": "Увімкніть словник у Налаштування → Мова → Словники.",
"No definitions found": "Визначень не знайдено",
"Search for {{word}} on the web.": "Шукати {{word}} в Інтернеті.",
"Dictionary unsupported": "Словник не підтримується",
"This dictionary format is not supported yet.": "Цей формат словника ще не підтримується.",
"Unable to load the word.": "Не вдалося завантажити слово.",
"Wiktionary": "Вікісловник",
"Drag to reorder": "Перетягніть, щоб змінити порядок",
"Built-in": "Вбудований",
@@ -1269,7 +1263,6 @@
"Import Dictionary": "Імпорт словника",
"No dictionaries available.": "Немає доступних словників.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Пакети StarDict потребують файлів .ifo, .idx і .dict.dz (.syn — необов’язково).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Пакети MDict використовують файли .mdx; супутні .mdd — необов’язково.",
"Select all the bundle files together when importing.": "Під час імпорту вибирайте всі файли пакета разом.",
"Manage Dictionaries": "Керування словниками",
"Select Dictionary Files": "Виберіть файли словника",
@@ -1374,5 +1367,18 @@
"Use %WORD% where the looked-up word should appear.": "Використовуйте %WORD% там, де має з'явитися шукане слово.",
"Open the search result in your browser:": "Відкрийте результат пошуку в браузері:",
"Open in {{name}}": "Відкрити в {{name}}",
"Book Fingerprint": "Відбиток книги"
"Book Fingerprint": "Відбиток книги",
"Search the web": "Пошук в інтернеті",
"App deeplink (readest://)": "Глибоке посилання застосунку (readest://)",
"Universal web link (https://)": "Універсальне веб-посилання (https://)",
"Name cannot be empty.": "Ім’я не може бути порожнім.",
"Replaced {{count}} existing dictionary_one": "Замінено {{count}} наявний словник",
"Replaced {{count}} existing dictionary_few": "Замінено {{count}} наявні словники",
"Replaced {{count}} existing dictionary_many": "Замінено {{count}} наявних словників",
"Replaced {{count}} existing dictionary_other": "Замінено {{count}} наявних словників",
"Cancel Edit": "Скасувати редагування",
"Edit Dictionary": "Редагувати словник",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Пакунки MDict використовують файли .mdx; супутні файли .mdd і .css є необов’язковими.",
"Dictionary name": "Назва словника",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Це аудіо не можна відтворити тут — словник використовує застарілий формат. Спробуйте словник з аудіо Opus, MP3 або WAV."
}
@@ -372,12 +372,6 @@
"No dictionaries enabled": "Lugʻatlar yoqilmagan",
"Enable a dictionary in Settings → Language → Dictionaries.": "Sozlamalar → Til → Lugʻatlar boʻlimida lugʻatni yoqing.",
"Manage Dictionaries": "Lugʻatlarni boshqarish",
"No definitions found": "Taʼriflar topilmadi",
"Search for {{word}} on the web.": "{{word}} ni internetda qidirish.",
"Dictionary unsupported": "Lugʻat qoʻllab-quvvatlanmaydi",
"This dictionary format is not supported yet.": "Ushbu lugʻat formati hali qoʻllab-quvvatlanmaydi.",
"Error": "Xato",
"Unable to load the word.": "Soʻzni yuklab boʻlmadi.",
"Exported from Readest": "Readest-dan eksport qilindi",
"Highlights & Annotations": "Belgilashlar va izohlar",
"Note:": "Eslatma:",
@@ -1091,7 +1085,6 @@
"No dictionaries available.": "Lugʻatlar mavjud emas.",
"Tips": "Maslahatlar",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict toʻplamlari .ifo, .idx va .dict.dz fayllarni talab qiladi (.syn ixtiyoriy).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict toʻplamlari .mdx fayllaridan foydalanadi; hamrohi .mdd fayllar ixtiyoriy.",
"DICT bundles need a .index file and a .dict.dz file.": "DICT toʻplamlari .index fayl va .dict.dz faylni talab qiladi.",
"Slob bundles need a .slob file.": "Slob toʻplamlari .slob faylni talab qiladi.",
"Select all the bundle files together when importing.": "Import qilishda toʻplamning barcha fayllarini birgalikda tanlang.",
@@ -1328,5 +1321,16 @@
"Sunset": "Quyosh botishi",
"Reveal in Finder": "Finder-da koʻrsatish",
"Reveal in File Explorer": "File Explorer-da koʻrsatish",
"Reveal in Folder": "Jildida koʻrsatish"
"Reveal in Folder": "Jildida koʻrsatish",
"Search the web": "Internetda qidirish",
"App deeplink (readest://)": "Ilova chuqur havolasi (readest://)",
"Universal web link (https://)": "Universal veb-havola (https://)",
"Name cannot be empty.": "Nom bosh bolishi mumkin emas.",
"Replaced {{count}} existing dictionary_one": "Mavjud {{count}} ta lugat almashtirildi",
"Replaced {{count}} existing dictionary_other": "Mavjud {{count}} ta lugat almashtirildi",
"Cancel Edit": "Tahrirni bekor qilish",
"Edit Dictionary": "Lugatni tahrirlash",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict toplamlari .mdx fayllaridan foydalanadi; hamroh .mdd va .css fayllari ixtiyoriy.",
"Dictionary name": "Lugat nomi",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Bu audio bu yerda ijro etilmaydi — lugat eskirgan formatdan foydalanadi. Opus, MP3 yoki WAV audioli lugatni sinab koring."
}
@@ -826,7 +826,6 @@
"Failed to export the book.": "Xuất sách thất bại.",
"Export Book": "Xuất sách",
"Whole word:": "Toàn bộ từ:",
"Error": "Lỗi",
"Date Published": "Ngày xuất bản",
"Only for TTS:": "Chỉ dành cho TTS:",
"Uploaded": "Đã tải lên",
@@ -1194,11 +1193,6 @@
"Failed to start stream": "Không thể bắt đầu stream",
"No dictionaries enabled": "Chưa kích hoạt từ điển nào",
"Enable a dictionary in Settings → Language → Dictionaries.": "Bật từ điển trong Cài đặt → Ngôn ngữ → Từ điển.",
"No definitions found": "Không tìm thấy định nghĩa",
"Search for {{word}} on the web.": "Tìm {{word}} trên web.",
"Dictionary unsupported": "Từ điển không được hỗ trợ",
"This dictionary format is not supported yet.": "Định dạng từ điển này chưa được hỗ trợ.",
"Unable to load the word.": "Không thể tải từ.",
"Wiktionary": "Wiktionary",
"Drag to reorder": "Kéo để sắp xếp lại",
"Built-in": "Tích hợp",
@@ -1215,7 +1209,6 @@
"Import Dictionary": "Nhập từ điển",
"No dictionaries available.": "Không có từ điển.",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "Gói StarDict cần các tệp .ifo, .idx và .dict.dz (.syn tuỳ chọn).",
"MDict bundles use .mdx files; companion .mdd files are optional.": "Gói MDict dùng tệp .mdx; tệp .mdd đi kèm là tuỳ chọn.",
"Select all the bundle files together when importing.": "Chọn tất cả các tệp của gói cùng lúc khi nhập.",
"Manage Dictionaries": "Quản lý từ điển",
"Select Dictionary Files": "Chọn tệp từ điển",
@@ -1305,5 +1298,15 @@
"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}}",
"Book Fingerprint": "Dấu vân tay sách"
"Book Fingerprint": "Dấu vân tay sách",
"Search the web": "Tìm kiếm trên web",
"App deeplink (readest://)": "Liên kết sâu của ứng dụng (readest://)",
"Universal web link (https://)": "Liên kết web phổ quát (https://)",
"Name cannot be empty.": "Tên không được để trống.",
"Replaced {{count}} existing dictionary_other": "Đã thay thế {{count}} từ điển hiện có",
"Cancel Edit": "Hủy chỉnh sửa",
"Edit Dictionary": "Chỉnh sửa từ điển",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Gói MDict sử dụng tệp .mdx; các tệp .mdd và .css đi kèm là tùy chọn.",
"Dictionary name": "Tên từ điển",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Không thể phát âm thanh này tại đây — từ điển dùng định dạng đã lỗi thời. Hãy thử từ điển có âm thanh Opus, MP3 hoặc WAV."
}
@@ -828,7 +828,6 @@
"Export Book": "导出书籍",
"Whole word:": "全词匹配:",
"Only for TTS:": "仅用于TTS",
"Error": "错误",
"Date Published": "出版日期",
"Uploaded": "已上传",
"Downloaded": "已下载",
@@ -1194,11 +1193,6 @@
"Failed to start stream": "启动流式传输失败",
"No dictionaries enabled": "未启用任何词典",
"Enable a dictionary in Settings → Language → Dictionaries.": "在 设置 → 语言 → 词典 中启用词典。",
"No definitions found": "未找到释义",
"Search for {{word}} on the web.": "在网页上搜索 {{word}}。",
"Dictionary unsupported": "词典不受支持",
"This dictionary format is not supported yet.": "尚不支持此词典格式。",
"Unable to load the word.": "无法加载该词。",
"Wiktionary": "维基词典",
"Drag to reorder": "拖动以重新排序",
"Built-in": "内置",
@@ -1215,7 +1209,6 @@
"Import Dictionary": "导入词典",
"No dictionaries available.": "没有可用的词典。",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict 词典包需要 .ifo、.idx 和 .dict.dz 文件(.syn 可选)。",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict 词典包使用 .mdx 文件;配套的 .mdd 文件为可选。",
"Select all the bundle files together when importing.": "导入时请同时选择词典包内的所有文件。",
"Manage Dictionaries": "管理词典",
"Select Dictionary Files": "选择词典文件",
@@ -1305,5 +1298,15 @@
"Use %WORD% where the looked-up word should appear.": "在查询词应出现的位置使用 %WORD%。",
"Open the search result in your browser:": "在浏览器中打开搜索结果:",
"Open in {{name}}": "在 {{name}} 中打开",
"Book Fingerprint": "书籍指纹"
"Book Fingerprint": "书籍指纹",
"Search the web": "网络搜索",
"App deeplink (readest://)": "应用深度链接 (readest://)",
"Universal web link (https://)": "通用网页链接 (https://)",
"Name cannot be empty.": "名称不能为空。",
"Replaced {{count}} existing dictionary_other": "已替换 {{count}} 个现有词典",
"Cancel Edit": "取消编辑",
"Edit Dictionary": "编辑词典",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict 包使用 .mdx 文件;附带的 .mdd 和 .css 文件可选。",
"Dictionary name": "词典名称",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "此处无法播放该音频 — 此词典使用过时格式。请尝试使用 Opus、MP3 或 WAV 音频的词典。"
}
@@ -826,7 +826,6 @@
"Failed to export the book.": "書籍匯出失敗。",
"Export Book": "匯出書籍",
"Whole word:": "全詞匹配:",
"Error": "錯誤",
"Date Published": "出版日期",
"Only for TTS:": "僅用於TTS",
"Uploaded": "已上傳",
@@ -1194,11 +1193,6 @@
"Failed to start stream": "無法啟動串流",
"No dictionaries enabled": "未啟用任何詞典",
"Enable a dictionary in Settings → Language → Dictionaries.": "在 設定 → 語言 → 詞典 中啟用詞典。",
"No definitions found": "未找到釋義",
"Search for {{word}} on the web.": "在網頁上搜尋 {{word}}。",
"Dictionary unsupported": "詞典不支援",
"This dictionary format is not supported yet.": "尚不支援此詞典格式。",
"Unable to load the word.": "無法載入此詞。",
"Wiktionary": "維基詞典",
"Drag to reorder": "拖曳以重新排序",
"Built-in": "內建",
@@ -1215,7 +1209,6 @@
"Import Dictionary": "匯入詞典",
"No dictionaries available.": "沒有可用的詞典。",
"StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).": "StarDict 詞典套件需要 .ifo、.idx 和 .dict.dz 檔案(.syn 選用)。",
"MDict bundles use .mdx files; companion .mdd files are optional.": "MDict 詞典套件使用 .mdx 檔案;搭配的 .mdd 檔案為選用。",
"Select all the bundle files together when importing.": "匯入時請同時選擇詞典套件內的所有檔案。",
"Manage Dictionaries": "管理詞典",
"Select Dictionary Files": "選擇詞典檔案",
@@ -1305,5 +1298,15 @@
"Use %WORD% where the looked-up word should appear.": "在查詢字詞應出現的位置使用 %WORD%。",
"Open the search result in your browser:": "在瀏覽器中開啟搜尋結果:",
"Open in {{name}}": "在 {{name}} 中開啟",
"Book Fingerprint": "書籍指紋"
"Book Fingerprint": "書籍指紋",
"Search the web": "網路搜尋",
"App deeplink (readest://)": "應用程式深層連結 (readest://)",
"Universal web link (https://)": "通用網頁連結 (https://)",
"Name cannot be empty.": "名稱不能為空。",
"Replaced {{count}} existing dictionary_other": "已替換 {{count}} 個現有辭典",
"Cancel Edit": "取消編輯",
"Edit Dictionary": "編輯辭典",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict 套件使用 .mdx 檔案;附帶的 .mdd 與 .css 檔案為選用。",
"Dictionary name": "辭典名稱",
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "無法在此播放此音訊 — 此辭典使用過時格式。請改用提供 Opus、MP3 或 WAV 音訊的辭典。"
}
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMdictProvider } from '@/services/dictionaries/providers/mdictProvider';
import type { ImportedDictionary } from '@/services/dictionaries/types';
@@ -54,6 +54,14 @@ const makeFs = () => ({
},
});
// MDict cards render the body in a shadow root so each dictionary's CSS
// stays scoped. Helper for tests that need to peek inside.
const getMdictShadow = (container: HTMLElement): ShadowRoot => {
const host = container.lastElementChild as HTMLElement | null;
if (!host?.shadowRoot) throw new Error('expected mdict shadow host as last child of container');
return host.shadowRoot;
};
describe('mdictProvider', () => {
it('opens an .mdx via Blob and looks up a real entry', async () => {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
@@ -64,8 +72,12 @@ describe('mdictProvider', () => {
});
expect(outcome.ok).toBe(true);
if (outcome.ok) expect(outcome.headword).toBe(KNOWN_HEADWORD);
expect(container.querySelector('h1')?.textContent).toBe(KNOWN_HEADWORD);
expect(container.querySelector('div')?.innerHTML.length).toBeGreaterThan(0);
// The headword surfaces either as our auto-prepended light-DOM <h1>
// or — when the dict body already includes one — inside the shadow.
const lightH1 = container.querySelector('h1')?.textContent?.trim();
const shadowText = getMdictShadow(container).textContent ?? '';
expect(lightH1 === KNOWN_HEADWORD || shadowText.includes(KNOWN_HEADWORD)).toBe(true);
expect(shadowText.length).toBeGreaterThan(0);
});
it('returns empty for an unknown headword', async () => {
@@ -279,6 +291,637 @@ describe('mdictProvider', () => {
expect(mdxBytesRead).toBeLessThan(realMdx.size * 0.5);
});
it('intercepts sound:// anchor clicks and plays via MDD lookup', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
const origMDDCreate = jsmdict.MDD.create.bind(jsmdict.MDD);
const locateMock = vi.fn(async (key: string) => ({
keyText: key,
data: new Uint8Array([0xff, 0xfb, 0x90, 0x00]),
}));
const playSpy = vi
.spyOn(window.HTMLMediaElement.prototype, 'play')
.mockResolvedValue(undefined);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
type FakeMDD = ReturnType<typeof origMDDCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
definition: `<span class='hw'>${word}</span> <a href="sound://test01.mp3">play</a>`,
}),
}) as unknown as FakeMDX;
jsmdict.MDD.create = async () =>
({
locateBytes: locateMock,
}) as unknown as FakeMDD;
try {
const provider = createMdictProvider({ dict: buildDict(true), fs: makeFs() });
const container = document.createElement('div');
await provider.lookup('hello', {
signal: new AbortController().signal,
container,
});
const anchor = getMdictShadow(container).querySelector(
'a[href^="sound://"]',
) as HTMLAnchorElement | null;
expect(anchor).not.toBeNull();
anchor!.click();
// Drain microtasks so the async click handler completes its lookup +
// URL.createObjectURL + audio.play() chain.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(locateMock).toHaveBeenCalledWith('test01.mp3');
expect(playSpy).toHaveBeenCalled();
expect(anchor!.getAttribute('data-mdd-resolved')).toMatch(/^blob:/);
// Second click reuses the cached blob URL — no extra MDD read.
locateMock.mockClear();
playSpy.mockClear();
anchor!.click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(locateMock).not.toHaveBeenCalled();
expect(playSpy).toHaveBeenCalled();
} finally {
jsmdict.MDX.create = origMDXCreate;
jsmdict.MDD.create = origMDDCreate;
playSpy.mockRestore();
}
});
it('skips playback for deprecated sound://*.spx clicks and dispatches a deprecation toast', async () => {
const jsmdict = await import('js-mdict');
const eventModule = await import('@/utils/event');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
const origMDDCreate = jsmdict.MDD.create.bind(jsmdict.MDD);
const locateMock = vi.fn();
const playSpy = vi
.spyOn(window.HTMLMediaElement.prototype, 'play')
.mockResolvedValue(undefined);
const dispatchSpy = vi.spyOn(eventModule.eventDispatcher, 'dispatch');
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
type FakeMDD = ReturnType<typeof origMDDCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
definition: `<span class='hw'>${word}</span> <a href="sound://door0001.spx">play</a>`,
}),
}) as unknown as FakeMDX;
jsmdict.MDD.create = async () =>
({
locateBytes: locateMock,
}) as unknown as FakeMDD;
try {
const provider = createMdictProvider({ dict: buildDict(true), fs: makeFs() });
const container = document.createElement('div');
await provider.lookup('hello', {
signal: new AbortController().signal,
container,
});
const anchor = getMdictShadow(container).querySelector(
'a[href^="sound://"]',
) as HTMLAnchorElement | null;
expect(anchor).not.toBeNull();
anchor!.click();
await new Promise((resolve) => setTimeout(resolve, 0));
// The .spx click short-circuits before any MDD or Audio work.
expect(locateMock).not.toHaveBeenCalled();
expect(playSpy).not.toHaveBeenCalled();
// A deprecation warning toast is dispatched.
const toastCall = dispatchSpy.mock.calls.find(([event]) => event === 'toast');
expect(toastCall).toBeDefined();
const payload = toastCall![1] as { type: string; message: string };
expect(payload.type).toBe('warning');
expect(payload.message).toMatch(/outdated format/i);
} finally {
jsmdict.MDX.create = origMDXCreate;
jsmdict.MDD.create = origMDDCreate;
playSpy.mockRestore();
dispatchSpy.mockRestore();
}
});
it('forwards entry:// anchor clicks to ctx.onNavigate (URL-decoded)', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
definition: `<span>see <a href="entry://timid">timid</a> and <a href="bword://hello%20world">hello world</a></span>`,
}),
}) as unknown as FakeMDX;
try {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
const container = document.createElement('div');
const onNavigate = vi.fn();
await provider.lookup('timidly', {
signal: new AbortController().signal,
container,
onNavigate,
});
const shadow = getMdictShadow(container);
const entryAnchor = shadow.querySelector('a[href^="entry://"]') as HTMLAnchorElement | null;
const bwordAnchor = shadow.querySelector('a[href^="bword://"]') as HTMLAnchorElement | null;
expect(entryAnchor).not.toBeNull();
expect(bwordAnchor).not.toBeNull();
entryAnchor!.click();
expect(onNavigate).toHaveBeenLastCalledWith('timid');
bwordAnchor!.click();
// bword:// alias resolves the same way, with URL-decoding applied.
expect(onNavigate).toHaveBeenLastCalledWith('hello world');
} finally {
jsmdict.MDX.create = origMDXCreate;
}
});
it('inlines a stylesheet referenced via <link rel=stylesheet> by reading bytes from MDD', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
const origMDDCreate = jsmdict.MDD.create.bind(jsmdict.MDD);
const cssText = '.hw{color:red}';
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
type FakeMDD = ReturnType<typeof origMDDCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
definition: `<link rel="stylesheet" href="mwa.css"><span class="hw">${word}</span>`,
}),
}) as unknown as FakeMDX;
const locateMock = vi.fn(async (key: string) => {
if (key === 'mwa.css') {
return { keyText: key, data: new TextEncoder().encode(cssText) };
}
return { keyText: key, data: null };
});
jsmdict.MDD.create = async () =>
({
locateBytes: locateMock,
}) as unknown as FakeMDD;
try {
const provider = createMdictProvider({ dict: buildDict(true), fs: makeFs() });
const container = document.createElement('div');
await provider.lookup('hello', {
signal: new AbortController().signal,
container,
});
const shadow = getMdictShadow(container);
// The original <link> is removed; its CSS is inlined as a <style>.
expect(shadow.querySelector('link')).toBeNull();
// The shadow has multiple <style> tags (baseline app rules + dict
// CSS). Match against any of them.
const styles = Array.from(shadow.querySelectorAll('style')).map((s) => s.textContent);
expect(styles).toContain(cssText);
expect(locateMock).toHaveBeenCalledWith('mwa.css');
} finally {
jsmdict.MDX.create = origMDXCreate;
jsmdict.MDD.create = origMDDCreate;
}
});
it('reads loose .css files at init and applies them inside every card shadow', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
definition: `<span>${word}</span>`,
}),
}) as unknown as FakeMDX;
const looseCss = '.entry{padding:1em}';
const dictWithCss: ImportedDictionary = {
id: 'mdict:fixture-with-css',
kind: 'mdict',
name: 'Fixture w/ CSS',
bundleDir: 'fixture-bundle',
files: {
mdx: MDX_FIXTURE_NAME,
css: ['style.css'],
},
addedAt: 1,
};
const fsWithCss = {
openFile: async (p: string, _base: BaseDir) => {
const base = p.split('/').pop()!;
if (base === MDX_FIXTURE_NAME) return readMdxFile();
if (base === 'style.css') return new File([looseCss], 'style.css', { type: 'text/css' });
throw new Error(`Unknown fixture file: ${base}`);
},
};
try {
const provider = createMdictProvider({ dict: dictWithCss, fs: fsWithCss });
const container = document.createElement('div');
await provider.lookup('hello', {
signal: new AbortController().signal,
container,
});
const shadow = getMdictShadow(container);
const styles = Array.from(shadow.querySelectorAll('style')).map((s) => s.textContent);
expect(styles).toContain(looseCss);
} finally {
jsmdict.MDX.create = origMDXCreate;
}
});
it('follows @@@LINK= content-level redirects to the canonical entry', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
const lookupCalls: string[] = [];
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => {
lookupCalls.push(word);
if (word === 'questions') {
return { keyText: 'questions', definition: '@@@LINK=question\n' };
}
if (word === 'question') {
return {
keyText: 'question',
definition: '<span class="def">a request for information</span>',
};
}
return { keyText: word, definition: null };
},
}) as unknown as FakeMDX;
try {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('questions', {
signal: new AbortController().signal,
container,
});
expect(outcome.ok).toBe(true);
// Two lookups: original + redirect target.
expect(lookupCalls).toEqual(['questions', 'question']);
// The shadow body shows the resolved entry's content, not the raw
// redirect string.
const shadowText = getMdictShadow(container).textContent ?? '';
expect(shadowText).not.toContain('@@@LINK=');
expect(shadowText).toContain('a request for information');
} finally {
jsmdict.MDX.create = origMDXCreate;
}
});
it('caps @@@LINK= chains at 5 hops to avoid infinite loops', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
const lookupCalls: string[] = [];
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
// a → b → a → b → ...
lookup: async (word: string) => {
lookupCalls.push(word);
if (word === 'a') return { keyText: 'a', definition: '@@@LINK=b' };
if (word === 'b') return { keyText: 'b', definition: '@@@LINK=a' };
return { keyText: word, definition: null };
},
}) as unknown as FakeMDX;
try {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
const container = document.createElement('div');
const outcome = await provider.lookup('a', {
signal: new AbortController().signal,
container,
});
// The chain is bounded; outcome resolves either way (the last
// result is rendered, even if it's still a redirect string).
expect(outcome.ok).toBe(true);
// Initial + 5 hops = 6 calls total.
expect(lookupCalls.length).toBeLessThanOrEqual(6);
} finally {
jsmdict.MDX.create = origMDXCreate;
}
});
it('rewrites url(...) refs in loose CSS to blob URLs sourced from MDD', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
const origMDDCreate = jsmdict.MDD.create.bind(jsmdict.MDD);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
type FakeMDD = ReturnType<typeof origMDDCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
definition: `<a class="play_pron"></a>`,
}),
}) as unknown as FakeMDX;
const locateMock = vi.fn(async (key: string) => {
if (key === 'sound.png') {
return { keyText: key, data: new Uint8Array([0x89, 0x50, 0x4e, 0x47]) };
}
return { keyText: key, data: null };
});
jsmdict.MDD.create = async () =>
({
locateBytes: locateMock,
}) as unknown as FakeMDD;
const looseCss = '.play_pron{background-image:url(sound.png);background-repeat:no-repeat;}';
const dictWithCss: ImportedDictionary = {
id: 'mdict:fixture-with-css-url',
kind: 'mdict',
name: 'Fixture w/ CSS url',
bundleDir: 'fixture-bundle',
files: { mdx: MDX_FIXTURE_NAME, mdd: ['fixture.mdd'], css: ['style.css'] },
addedAt: 1,
};
const fsWithCss = {
openFile: async (p: string, _base: BaseDir) => {
const base = p.split('/').pop()!;
if (base === MDX_FIXTURE_NAME) return readMdxFile();
if (base === 'fixture.mdd') return new File([new Uint8Array()], 'fixture.mdd');
if (base === 'style.css') return new File([looseCss], 'style.css', { type: 'text/css' });
throw new Error(`Unknown fixture file: ${base}`);
},
};
try {
const provider = createMdictProvider({ dict: dictWithCss, fs: fsWithCss });
const container = document.createElement('div');
await provider.lookup('hello', {
signal: new AbortController().signal,
container,
});
const shadow = getMdictShadow(container);
const styleText = Array.from(shadow.querySelectorAll('style'))
.map((s) => s.textContent)
.join('\n');
// The original `url(sound.png)` is rewritten to a blob URL.
expect(styleText).not.toContain('url(sound.png)');
expect(styleText).toMatch(/url\("blob:[^"]+"\)/);
expect(locateMock).toHaveBeenCalledWith('sound.png');
} finally {
jsmdict.MDX.create = origMDXCreate;
jsmdict.MDD.create = origMDDCreate;
}
});
it('rewrites url(...) refs inside MDD-resident <link> stylesheets too', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
const origMDDCreate = jsmdict.MDD.create.bind(jsmdict.MDD);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
type FakeMDD = ReturnType<typeof origMDDCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
definition: `<link rel="stylesheet" href="mwa.css"><span>${word}</span>`,
}),
}) as unknown as FakeMDX;
const cssWithUrl = '.icon{background-image:url("img/spk.png")}';
const locateMock = vi.fn(async (key: string) => {
if (key === 'mwa.css') {
return { keyText: key, data: new TextEncoder().encode(cssWithUrl) };
}
if (key === 'img/spk.png') {
return { keyText: key, data: new Uint8Array([0x89, 0x50, 0x4e, 0x47]) };
}
return { keyText: key, data: null };
});
jsmdict.MDD.create = async () =>
({
locateBytes: locateMock,
}) as unknown as FakeMDD;
try {
const provider = createMdictProvider({ dict: buildDict(true), fs: makeFs() });
const container = document.createElement('div');
await provider.lookup('hello', {
signal: new AbortController().signal,
container,
});
const shadow = getMdictShadow(container);
const styleText = Array.from(shadow.querySelectorAll('style'))
.map((s) => s.textContent)
.join('\n');
expect(styleText).not.toContain('img/spk.png');
expect(styleText).toMatch(/url\("blob:[^"]+"\)/);
expect(locateMock).toHaveBeenCalledWith('img/spk.png');
} finally {
jsmdict.MDX.create = origMDXCreate;
jsmdict.MDD.create = origMDDCreate;
}
});
it('hides the auto-prepended headword when the dict body already includes one with matching text', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
definition: `<h1 class="dict-h1"> ${word} </h1><p>def</p>`,
}),
}) as unknown as FakeMDX;
try {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
const container = document.createElement('div');
await provider.lookup('question', {
signal: new AbortController().signal,
container,
});
// Light-DOM h1 (our auto-prepended one) is removed when the dict's
// own h1 matches; only the shadow's h1 remains visible.
expect(container.querySelector('h1')).toBeNull();
const shadowH1 = getMdictShadow(container).querySelector('h1');
expect(shadowH1?.textContent?.trim()).toBe('question');
} finally {
jsmdict.MDX.create = origMDXCreate;
}
});
it('hides the auto-prepended headword when the dict body starts with a non-h1 element matching the word', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
// Many dicts prefix the entry with a non-h1 styled headword (e.g.
// <h3 class="entry_name">探春</h3>). The shell should still
// de-duplicate against our auto-prepended h1.
lookup: async (word: string) => ({
keyText: word,
definition: `<h3 class="entry_name">${word}</h3><div>def</div>`,
}),
}) as unknown as FakeMDX;
try {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
const container = document.createElement('div');
await provider.lookup('探春', {
signal: new AbortController().signal,
container,
});
// Light-DOM h1 is removed because the dict body's first child is an
// h3 with the same trimmed text.
expect(container.querySelector('h1')).toBeNull();
const shadowH3 = getMdictShadow(container).querySelector('h3.entry_name');
expect(shadowH3?.textContent?.trim()).toBe('探春');
} finally {
jsmdict.MDX.create = origMDXCreate;
}
});
it('tags the in-shadow body with data-dict-kind so external tooling / CSS can target it', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
definition: `<p>def</p>`,
}),
}) as unknown as FakeMDX;
try {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
const container = document.createElement('div');
await provider.lookup('hello', {
signal: new AbortController().signal,
container,
});
const tagged = getMdictShadow(container).querySelector('[data-dict-kind="mdict"]');
expect(tagged).not.toBeNull();
} finally {
jsmdict.MDX.create = origMDXCreate;
}
});
it('keeps the auto-prepended headword when the dict body has a different h1 text', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
definition: `<h1>question (verb)</h1><p>def</p>`,
}),
}) as unknown as FakeMDX;
try {
const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() });
const container = document.createElement('div');
await provider.lookup('question', {
signal: new AbortController().signal,
container,
});
// The auto-prepended h1 stays because the dict's h1 text differs.
expect(container.querySelector('h1')?.textContent).toBe('question');
} finally {
jsmdict.MDX.create = origMDXCreate;
}
});
it('resolves <img src="/path"> against the MDD (treating leading slash as MDD-relative)', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
const origMDDCreate = jsmdict.MDD.create.bind(jsmdict.MDD);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
type FakeMDD = ReturnType<typeof origMDDCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
// MW-style absolute-from-root reference inside the MDX HTML.
definition: `<div class="art"><img src="/images/door_rev.png"></div>`,
}),
}) as unknown as FakeMDX;
// The MDD only stores it under the leading-slash-stripped key, so the
// fallback retry must kick in.
const locateMock = vi.fn(async (key: string) => {
if (key === 'images/door_rev.png') {
return { keyText: key, data: new Uint8Array([0x89, 0x50, 0x4e, 0x47]) };
}
return { keyText: key, data: null };
});
jsmdict.MDD.create = async () =>
({
locateBytes: locateMock,
}) as unknown as FakeMDD;
try {
const provider = createMdictProvider({ dict: buildDict(true), fs: makeFs() });
const container = document.createElement('div');
await provider.lookup('door', {
signal: new AbortController().signal,
container,
});
const img = getMdictShadow(container).querySelector('img') as HTMLImageElement | null;
expect(img).not.toBeNull();
expect(img!.getAttribute('src')).toMatch(/^blob:/);
expect(locateMock).toHaveBeenCalledWith('/images/door_rev.png');
expect(locateMock).toHaveBeenCalledWith('images/door_rev.png');
} finally {
jsmdict.MDX.create = origMDXCreate;
jsmdict.MDD.create = origMDDCreate;
}
});
it('repeated lookups of the same word produce identical, bounded reads', async () => {
const counter = makeReadCounter();
const fs = withReadCounting(makeFs(), counter);
@@ -94,4 +94,33 @@ describe('customDictionaryStore — web search CRUD', () => {
const after = useCustomDictionaryStore.getState().settings;
expect(after.providerOrder.includes(BUILTIN_WEB_SEARCH_IDS.google)).toBe(true);
});
it('updateDictionary patches the display name (trimmed) and ignores empty / unchanged input', () => {
const { addDictionary, updateDictionary } = useCustomDictionaryStore.getState();
addDictionary({
id: 'mdict:abc',
kind: 'mdict',
name: 'Title (No HTML code allowed)',
bundleDir: 'abc',
files: { mdx: 'abc.mdx' },
addedAt: 1,
});
updateDictionary('mdict:abc', { name: ' Webster MW11 ' });
let dict = useCustomDictionaryStore.getState().dictionaries.find((d) => d.id === 'mdict:abc');
expect(dict?.name).toBe('Webster MW11');
// Same name (no-op).
updateDictionary('mdict:abc', { name: 'Webster MW11' });
dict = useCustomDictionaryStore.getState().dictionaries.find((d) => d.id === 'mdict:abc');
expect(dict?.name).toBe('Webster MW11');
// Empty / whitespace patch is rejected — keep existing name.
updateDictionary('mdict:abc', { name: ' ' });
dict = useCustomDictionaryStore.getState().dictionaries.find((d) => d.id === 'mdict:abc');
expect(dict?.name).toBe('Webster MW11');
// Unknown id: silent no-op.
expect(() => updateDictionary('mdict:nope', { name: 'X' })).not.toThrow();
});
});
@@ -7,6 +7,7 @@ import { openUrl } from '@tauri-apps/plugin-opener';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { getEnabledProviders } from '@/services/dictionaries/registry';
import { isTauriAppPlatform } from '@/services/environment';
@@ -68,6 +69,7 @@ export function useDictionaryResults({
}: UseDictionaryResultsArgs): DictionaryResultsState {
const { appService } = useEnv();
const { dictionaries, settings } = useCustomDictionaryStore();
const isDarkMode = useThemeStore((s) => s.isDarkMode);
const computedProviders = getEnabledProviders({
settings,
@@ -212,6 +214,7 @@ export function useDictionaryResults({
signal: controller.signal,
container,
onNavigate: pushWord,
isDarkMode,
});
}
} catch (err) {
@@ -239,7 +242,7 @@ export function useDictionaryResults({
});
return () => controllers.forEach((c) => c.abort());
}, [currentWord, definitionProviders, lang, pushWord]);
}, [currentWord, definitionProviders, lang, pushWord, isDarkMode]);
// Visible cards = providers that are still loading or finished with a
// result. Empty/unsupported/error cards are removed entirely.
@@ -310,7 +313,7 @@ export const DictionaryResultsHeader: React.FC<DictionaryResultsHeaderProps> = (
}) => {
const _ = useTranslation();
return (
<div className='flex h-8 w-full items-center justify-between px-2'>
<div className='-mt-3 flex h-8 w-full items-center justify-between px-2'>
<div className='flex h-8 w-8 items-center justify-center'>
{canGoBack ? (
<button
@@ -391,8 +394,14 @@ export const DictionaryResultsBody: React.FC<DictionaryResultsBodyProps> = ({
tabIndex={0}
aria-expanded={expanded}
onClick={(e) => {
const target = e.target as Element | null;
if (target?.closest('a, button')) return;
const path = e.nativeEvent.composedPath();
for (const node of path) {
if (node === e.currentTarget) break;
if (node instanceof Element) {
const tag = node.tagName;
if (tag === 'A' || tag === 'BUTTON' || tag === 'IMG') return;
}
}
toggleExpanded(p.id);
}}
onKeyDown={(e) => {
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { MdAdd, MdDelete, MdDragIndicator, MdInfoOutline } from 'react-icons/md';
import { MdAdd, MdDelete, MdDragIndicator, MdEdit, MdInfoOutline } from 'react-icons/md';
import { IoMdCloseCircleOutline } from 'react-icons/io';
import {
DndContext,
@@ -67,9 +67,11 @@ interface SortableRowProps {
row: ProviderRow;
enabled: boolean;
isDeleteMode: boolean;
isEditMode: boolean;
onToggle: (id: string, next: boolean) => void;
onDelete: (row: ProviderRow) => void;
onEditWebSearch?: (entry: WebSearchEntry) => void;
onEditDict?: (dict: ImportedDictionary) => void;
_: (key: string, options?: Record<string, number | string>) => string;
}
@@ -77,9 +79,11 @@ const SortableRow: React.FC<SortableRowProps> = ({
row,
enabled,
isDeleteMode,
isEditMode,
onToggle,
onDelete,
onEditWebSearch,
onEditDict,
_,
}) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -126,15 +130,6 @@ const SortableRow: React.FC<SortableRowProps> = ({
>
{row.label}
</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'>
@@ -158,6 +153,27 @@ const SortableRow: React.FC<SortableRowProps> = ({
aria-label={enabled ? _('Disable') : _('Enable')}
/>
{/* Edit pencil parity with the trailing delete X, but for the
rename / re-template flow. Visible only in edit mode for rows
backed by user-mutable metadata (imported dicts and custom web
searches; built-ins are immutable). */}
{(row.imported || (row.kind === 'web' && !row.builtinWeb)) && isEditMode && (
<button
type='button'
onClick={() => {
if (row.imported && onEditDict) onEditDict(row.imported);
else if (row.kind === 'web' && row.webSearch && onEditWebSearch) {
onEditWebSearch(row.webSearch);
}
}}
className='btn btn-ghost btn-sm shrink-0 px-1'
aria-label={_('Edit')}
title={_('Edit')}
>
<MdEdit className='text-base-content/75 h-4 w-4' />
</button>
)}
{/* 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
@@ -184,7 +200,9 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
dictionaries,
settings,
addDictionary,
replaceDictionaries,
removeDictionary,
updateDictionary,
reorder,
setEnabled,
addWebSearch,
@@ -201,7 +219,22 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
const { selectFiles } = useFileSelector(appService, _);
const [importing, setImporting] = useState(false);
// Edit and Delete are mutually-exclusive row affordances. Toggling one on
// turns the other off so the trailing column never shows two icons at once.
const [isDeleteMode, setIsDeleteMode] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const toggleDeleteMode = () =>
setIsDeleteMode((v) => {
const next = !v;
if (next) setIsEditMode(false);
return next;
});
const toggleEditMode = () =>
setIsEditMode((v) => {
const next = !v;
if (next) setIsDeleteMode(false);
return next;
});
// Add/edit web-search modal state. `editingId` is `null` for "add", a
// custom entry's id for "edit".
@@ -237,6 +270,30 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
setWebModal(null);
};
// Edit-imported-dict modal. Only the display `name` is editable; the
// bundle on disk is untouched.
const [dictModal, setDictModal] = useState<null | { id: string; name: string }>(null);
const openEditDict = (dict: ImportedDictionary) => setDictModal({ id: dict.id, name: dict.name });
const closeDictModal = () => setDictModal(null);
const submitDictModal = async () => {
if (!dictModal) return;
const name = dictModal.name.trim();
if (!name) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Name cannot be empty.'),
timeout: 4000,
});
return;
}
updateDictionary(dictModal.id, { name });
// Provider instances cache the dict's `label` from `dict.name`; evict
// so the next lookup picks up the new name in tabs / source labels.
evictProvider(dictModal.id);
await saveCustomDictionaries(envConfig);
setDictModal(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]));
@@ -327,13 +384,21 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
try {
const result = await selectFiles({ type: 'dictionaries', multiple: true });
if (result.error || result.files.length === 0) return;
const importResult = await appService?.importDictionaries(result.files);
const importResult = await appService?.importDictionaries(result.files, dictionaries);
if (!importResult) return;
let added = 0;
for (const dict of importResult.imported) {
addDictionary(dict);
added += 1;
}
let replaced = 0;
for (const { oldIds, newDict } of importResult.replacements) {
replaceDictionaries(oldIds, newDict);
// Invalidate any cached provider instances for the replaced ids so
// their next lookup picks up the new bundle's files.
for (const oldId of oldIds) evictProvider(oldId);
replaced += 1;
}
await saveCustomDictionaries(envConfig);
if (added > 0) {
eventDispatcher.dispatch('toast', {
@@ -342,6 +407,13 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
timeout: 2500,
});
}
if (replaced > 0) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Replaced {{count}} existing dictionary', { count: replaced }),
timeout: 2500,
});
}
if (importResult.orphanFiles.length > 0) {
eventDispatcher.dispatch('toast', {
type: 'warning',
@@ -382,11 +454,14 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
}
await saveCustomDictionaries(envConfig);
// Auto-leave delete mode when the last deletable entry is gone — there's
// nothing left to delete.
// nothing left to delete (edit mode is gated on the same row set).
const remaining = rows.filter(
(r) => r.id !== row.id && (r.imported || (r.kind === 'web' && !r.builtinWeb)),
);
if (remaining.length === 0) setIsDeleteMode(false);
if (remaining.length === 0) {
setIsDeleteMode(false);
setIsEditMode(false);
}
};
const handleToggle = async (id: string, next: boolean) => {
@@ -410,7 +485,7 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
return (
<div className='w-full'>
<div className='mb-6 flex h-8 items-center justify-between'>
<div className='mb-4 flex h-8 items-center justify-between'>
<div className='breadcrumbs py-1'>
<ul>
<li>
@@ -422,49 +497,41 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
</ul>
</div>
{hasDeletable && (
<button
onClick={() => setIsDeleteMode((v) => !v)}
className='btn btn-ghost btn-sm text-base-content gap-2'
title={isDeleteMode ? _('Cancel Delete') : _('Delete Dictionary')}
>
{isDeleteMode ? (
<>{_('Cancel')}</>
) : (
<>
<MdDelete className='h-4 w-4' />
{_('Delete')}
</>
)}
</button>
<div className='flex items-center gap-1'>
<button
onClick={toggleEditMode}
className='btn btn-ghost btn-sm text-base-content gap-2'
title={isEditMode ? _('Cancel Edit') : _('Edit Dictionary')}
>
{isEditMode ? (
<>{_('Cancel')}</>
) : (
<>
<MdEdit className='h-4 w-4' />
{/* Hide label on very narrow screens so the icon-only
button keeps the breadcrumb readable. */}
<span className='hidden min-[400px]:inline'>{_('Edit')}</span>
</>
)}
</button>
<button
onClick={toggleDeleteMode}
className='btn btn-ghost btn-sm text-base-content gap-2'
title={isDeleteMode ? _('Cancel Delete') : _('Delete Dictionary')}
>
{isDeleteMode ? (
<>{_('Cancel')}</>
) : (
<>
<MdDelete className='h-4 w-4' />
<span className='hidden min-[400px]:inline'>{_('Delete')}</span>
</>
)}
</button>
</div>
)}
</div>
{/* 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'
onClick={handleImport}
disabled={importing}
className='btn btn-outline btn-primary gap-2 normal-case'
>
<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 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
{rows.length === 0 && (
@@ -484,9 +551,11 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
row={row}
enabled={settings.providerEnabled[row.id] !== false}
isDeleteMode={isDeleteMode}
isEditMode={isEditMode}
onToggle={handleToggle}
onDelete={handleDelete}
onEditWebSearch={openEditWebSearch}
onEditDict={openEditDict}
_={_}
/>
))}
@@ -495,6 +564,28 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
</div>
</div>
<div className='mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2'>
<button
type='button'
onClick={handleImport}
disabled={importing}
className='btn btn-outline btn-primary gap-2 normal-case [--animation-btn:0s]'
>
<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 [--animation-btn:0s]'
>
<MdAdd className='h-5 w-5' />
<span className='line-clamp-1'>{_('Add Web Search')}</span>
</button>
</div>
<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'>
@@ -503,7 +594,9 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
</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>
{_('MDict bundles use .mdx files; companion .mdd and .css 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>
@@ -565,6 +658,42 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
/>
</div>
)}
{/* Edit-imported-dict modal. Single field for the display name; the
on-disk bundle is untouched. */}
{dictModal && (
<div className='modal modal-open' role='dialog'>
<div className='modal-box w-11/12 max-w-md'>
<h3 className='text-base font-semibold'>{_('Edit Dictionary')}</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={dictModal.name}
placeholder={_('Dictionary name')}
onChange={(e) => setDictModal((m) => (m ? { ...m, name: e.target.value } : m))}
/>
</label>
</div>
<div className='modal-action'>
<button type='button' onClick={closeDictModal} className='btn btn-ghost btn-sm'>
{_('Cancel')}
</button>
<button type='button' onClick={submitDictModal} className='btn btn-primary btn-sm'>
{_('Save')}
</button>
</div>
</div>
<button
type='button'
aria-label={_('Close')}
className='modal-backdrop'
onClick={closeDictModal}
/>
</div>
)}
</div>
);
};
@@ -140,8 +140,8 @@ export const FILE_SELECTION_PRESETS = {
dialogTitle: _('Select Fonts'),
},
dictionaries: {
accept: '.mdx, .mdd, .ifo, .idx, .dict, .dz, .syn, .index, .slob',
extensions: ['mdx', 'mdd', 'ifo', 'idx', 'dict', 'dz', 'syn', 'index', 'slob'],
accept: '.mdx, .mdd, .ifo, .idx, .dict, .dz, .syn, .index, .slob, .css',
extensions: ['mdx', 'mdd', 'ifo', 'idx', 'dict', 'dz', 'syn', 'index', 'slob', 'css'],
dialogTitle: _('Select Dictionary Files'),
},
covers: {
+5 -2
View File
@@ -231,8 +231,11 @@ export abstract class BaseAppService implements AppService {
return ImageSvc.deleteImage(this.fs, texture);
}
async importDictionaries(files: SelectedFile[]): Promise<DictSvc.ImportDictionariesResult> {
return DictSvc.importDictionaries(this.fs, files);
async importDictionaries(
files: SelectedFile[],
existingDictionaries: ImportedDictionary[] = [],
): Promise<DictSvc.ImportDictionariesResult> {
return DictSvc.importDictionaries(this.fs, files, existingDictionaries);
}
async deleteDictionary(dict: ImportedDictionary): Promise<void> {
@@ -46,6 +46,12 @@ interface MDictGroup {
stem: string;
mdx: SourceFile;
mdd: SourceFile[];
/**
* Loose `.css` files sharing the bundle stem (e.g. `mydict.mdx` +
* `mydict.css`). Optional. Applied at lookup time as scoped stylesheets
* inside the card's shadow root.
*/
css: SourceFile[];
}
interface DictGroup {
@@ -117,8 +123,13 @@ function classify(source: SelectedFile): SourceFile {
*/
export function groupBundlesByStem(files: SelectedFile[]): GroupResult {
const classified = files.map(classify);
// `.css` files don't have to share a stem with the `.mdx` (e.g. an MDX
// entry may reference `mwa.css` while the dictionary is `MW11sound.mdx`).
// Pool them globally and attach to every MDict bundle in this import.
const cssFiles = classified.filter((f) => f.ext === 'css');
const byStem = new Map<string, SourceFile[]>();
for (const f of classified) {
if (f.ext === 'css') continue;
if (!byStem.has(f.stem)) byStem.set(f.stem, []);
byStem.get(f.stem)!.push(f);
}
@@ -140,13 +151,26 @@ export function groupBundlesByStem(files: SelectedFile[]): GroupResult {
} else if (indexFile && dict) {
bundles.push({ kind: 'dict', stem, index: indexFile, dict });
} else if (mdx) {
bundles.push({ kind: 'mdict', stem, mdx, mdd });
// `css` is filled in below once we know which MDict bundles exist.
bundles.push({ kind: 'mdict', stem, mdx, mdd, css: [] });
} else if (slob) {
bundles.push({ kind: 'slob', stem, slob });
} else {
orphans.push(...group);
}
}
// Distribute all loose `.css` files across the MDict bundles in this
// import. With one dictionary at a time (the common case) every selected
// `.css` ends up applied; with multiple, each gets the full set — benign
// because the per-card shadow root scopes the styles anyway.
const mdictBundles = bundles.filter((b): b is MDictGroup => b.kind === 'mdict');
if (mdictBundles.length > 0) {
for (const b of mdictBundles) b.css = cssFiles;
} else {
orphans.push(...cssFiles);
}
return { bundles, orphans };
}
@@ -277,11 +301,15 @@ async function importMdictBundle(fs: FileSystem, group: MDictGroup): Promise<Imp
const bundleDir = await createBundleDir(fs);
const mdxFile = await readSource(fs, group.mdx.source);
const mddFiles = await Promise.all(group.mdd.map((m) => readSource(fs, m.source)));
const cssFiles = await Promise.all(group.css.map((c) => readSource(fs, c.source)));
await writeBundleFile(fs, bundleDir, group.mdx.name, mdxFile);
for (let i = 0; i < group.mdd.length; i++) {
await writeBundleFile(fs, bundleDir, group.mdd[i]!.name, mddFiles[i]!);
}
for (let i = 0; i < group.css.length; i++) {
await writeBundleFile(fs, bundleDir, group.css[i]!.name, cssFiles[i]!);
}
// Parse the MDX header via the forked js-mdict (browser-friendly path).
// Loaded lazily so users without MDict imports never pull in the parser.
@@ -328,6 +356,7 @@ async function importMdictBundle(fs: FileSystem, group: MDictGroup): Promise<Imp
files: {
mdx: group.mdx.name,
mdd: group.mdd.map((m) => m.name),
css: group.css.length ? group.css.map((c) => c.name) : undefined,
},
lang,
addedAt: Date.now(),
@@ -446,33 +475,92 @@ async function importSlobBundle(fs: FileSystem, group: SlobGroup): Promise<Impor
export interface ImportDictionariesResult {
imported: ImportedDictionary[];
/**
* Bundles whose name matched one or more existing dictionaries in the
* user's library. The duplicate's old bundle dir has been removed from
* disk; the caller still needs to update the store drop `oldIds`,
* insert `newDict` in the first old entry's `providerOrder` slot, and
* inherit the first old entry's enabled flag.
*/
replacements: { oldIds: string[]; newDict: ImportedDictionary }[];
/** Filenames that didn't form a valid bundle. */
orphanFiles: string[];
}
/**
* Top-level import entry point. Groups the selected files into bundles and
* imports each one. Returns the persisted metadata for new entries plus a
* list of orphan filenames the caller can surface in a toast.
* imports each one. When a freshly-imported bundle's name matches an
* existing (non-deleted) dictionary, the existing on-disk bundle dirs are
* removed and the new dict is reported in `replacements` so the caller can
* swap the store entry in place (preserving the position in
* `providerOrder` and the enabled flag).
*/
export async function importDictionaries(
fs: FileSystem,
files: SelectedFile[],
existingDictionaries: ImportedDictionary[] = [],
): Promise<ImportDictionariesResult> {
const { bundles, orphans } = groupBundlesByStem(files);
// Index existing entries by name. Multiple entries can share a name when
// the user previously imported the same dict more than once — we replace
// them all with the single new bundle.
const existingByName = new Map<string, ImportedDictionary[]>();
for (const d of existingDictionaries) {
if (d.deletedAt) continue;
const list = existingByName.get(d.name);
if (list) list.push(d);
else existingByName.set(d.name, [d]);
}
const imported: ImportedDictionary[] = [];
const replacements: { oldIds: string[]; newDict: ImportedDictionary }[] = [];
// Names already added during this single import call. A second bundle in
// the same selection that shares a name with an earlier one is dropped
// (the first wins) so we don't end up with intra-call duplicates.
const seenNames = new Set<string>();
for (const bundle of bundles) {
let dict: ImportedDictionary;
if (bundle.kind === 'stardict') {
imported.push(await importStarDictBundle(fs, bundle));
dict = await importStarDictBundle(fs, bundle);
} else if (bundle.kind === 'mdict') {
imported.push(await importMdictBundle(fs, bundle));
dict = await importMdictBundle(fs, bundle);
} else if (bundle.kind === 'dict') {
imported.push(await importDictBundle(fs, bundle));
dict = await importDictBundle(fs, bundle);
} else {
imported.push(await importSlobBundle(fs, bundle));
dict = await importSlobBundle(fs, bundle);
}
if (seenNames.has(dict.name)) {
try {
await fs.removeDir(dict.bundleDir, 'Dictionaries', true);
} catch (err) {
console.warn('Failed to clean up duplicate bundle dir', dict.bundleDir, err);
}
continue;
}
seenNames.add(dict.name);
const olds = existingByName.get(dict.name);
if (olds && olds.length > 0) {
for (const old of olds) {
try {
await fs.removeDir(old.bundleDir, 'Dictionaries', true);
} catch (err) {
console.warn('Failed to remove replaced bundle dir', old.bundleDir, err);
}
}
replacements.push({ oldIds: olds.map((o) => o.id), newDict: dict });
} else {
imported.push(dict);
}
}
return { imported, orphanFiles: orphans.map((o) => o.name) };
return {
imported,
replacements,
orphanFiles: orphans.map((o) => o.name),
};
}
/** Remove a dictionary's bundle directory. The metadata is dropped by the caller. */
@@ -16,6 +16,9 @@
* Encrypted MDX is detected at `init()` (the constructor sets
* `meta.encrypt`) and surfaces as `unsupported`.
*/
import { eventDispatcher } from '@/utils/event';
import { stubTranslation as _ } from '@/utils/misc';
import { getDictStyles } from '@/utils/style';
import type { DictionaryProvider, ImportedDictionary } from '../types';
import type { DictionaryFileOpener } from './starDictProvider';
@@ -53,7 +56,17 @@ export interface CreateMdictProviderArgs {
label?: string;
}
const IMG_SRC_PROTOCOL_RX = /^(?:[a-z]+:|data:|blob:|\/)/i;
// Match real URL schemes (`http:`, `data:`, `blob:`, `file:`, …) and the
// protocol-relative `//host/...` form. A bare leading `/` is NOT skipped:
// MDX entries often reference resources via paths like `/images/foo.png`,
// which are intra-MDD relative paths (not document-absolute), so the
// resolver should still try them via `mdd.locateBytes`.
const IMG_SRC_PROTOCOL_RX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
const SOUND_HREF_RX = /^sound:\/\//i;
// `entry://word` is the canonical MDict cross-reference. `bword://word` is an
// alias used by some Babylon-derived dictionaries; both should re-look-up the
// target headword in the popup.
const ENTRY_HREF_RX = /^(?:entry|bword):\/\//i;
/**
* Resolve `<img src="path">` references in the rendered HTML by reading bytes
@@ -75,23 +88,218 @@ async function resolveImageResources(
if (signal.aborted) return;
const src = img.getAttribute('src');
if (!src || IMG_SRC_PROTOCOL_RX.test(src)) return;
// Try the path as-is first; if the MDD doesn't have it, retry with
// a single leading `/` stripped (some MDX bundles emit
// `/images/foo.png` while the MDD stores `images/foo.png`).
const candidates = src.startsWith('/') ? [src, src.slice(1)] : [src];
for (const mdd of mdds) {
for (const key of candidates) {
try {
const located = await mdd.locateBytes(key);
if (signal.aborted) return;
if (located.data) {
const blob = new Blob([new Uint8Array(located.data)]);
const url = URL.createObjectURL(blob);
trackedUrls.push(url);
img.setAttribute('src', url);
return;
}
} catch (err) {
console.warn('mdd.locateBytes failed for', key, err);
}
}
}
}),
);
}
/**
* Wire MDict-specific URL schemes inside the rendered HTML:
*
* - `sound://path.ext` audio resource in the companion `.mdd`. Click looks
* up the bytes via `MDD.locateBytes` (auto-normalized; raw path works) and
* plays them through an `Audio` element. The first successful lookup caches
* the blob URL on the anchor for subsequent clicks; URLs are tracked for
* revocation in `dispose()`. `.spx` (Speex) is short-circuited with a toast
* since no major browser decodes it.
* - `entry://word` / `bword://word` cross-reference to another headword in
* the same dictionary. Click forwards to `ctx.onNavigate(word)`, which the
* shell turns into a re-lookup. URL-encoded targets (e.g. `entry://word%20here`)
* are decoded before forwarding.
*
* Other schemes (`http(s)://`, `file://`, etc.) bubble up and are handled by
* the surrounding shell's container click delegation.
*/
/**
* Resolve any `<link rel="stylesheet" href="X.css">` references in the
* rendered MDX body against the companion `.mdd` bundle(s). Returns the CSS
* text for each successfully-located stylesheet (ready to be injected as an
* inline `<style>` inside the card's shadow root). The original `<link>`
* elements are removed from the body so the browser doesn't try to fetch
* them via the network.
*/
async function resolveMddStylesheets(
body: HTMLElement,
mdds: MDDInstance[],
signal: AbortSignal,
): Promise<string[]> {
const links = Array.from(body.querySelectorAll<HTMLLinkElement>('link[rel~="stylesheet"][href]'));
const out: string[] = [];
for (const link of links) {
const href = link.getAttribute('href');
link.remove();
if (!href || !mdds.length) continue;
for (const mdd of mdds) {
try {
const located = await mdd.locateBytes(href);
if (signal.aborted) return out;
if (located.data) {
out.push(new TextDecoder('utf-8').decode(located.data));
break;
}
} catch (err) {
console.warn('mdd.locateBytes failed for stylesheet', href, err);
}
}
}
return out;
}
/**
* Rewrite `url(path)` references in a stylesheet's text against the
* companion `.mdd` bundle(s). Each non-protocol path is resolved to bytes
* via `MDD.locateBytes` and replaced with a blob URL. Absolute URLs
* (`http(s)://`, `data:`, `blob:`, leading `/`) are left untouched.
*
* Without this rewrite, relative urls inside an inlined `<style>` resolve
* against the document base which has no idea how to fetch the dict's
* sound icon / background image / `@font-face` source from the MDD.
*/
const CSS_URL_RX = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]+?))\s*\)/g;
async function resolveCssUrls(
cssText: string,
mdds: MDDInstance[],
signal: AbortSignal,
trackedUrls: string[],
): Promise<string> {
if (!mdds.length || !cssText.includes('url(')) return cssText;
const paths = new Set<string>();
for (const m of cssText.matchAll(CSS_URL_RX)) {
const path = (m[1] ?? m[2] ?? m[3] ?? '').trim();
if (!path) continue;
if (IMG_SRC_PROTOCOL_RX.test(path)) continue;
paths.add(path);
}
if (paths.size === 0) return cssText;
const resolved = new Map<string, string>();
await Promise.all(
Array.from(paths).map(async (path) => {
for (const mdd of mdds) {
if (signal.aborted) return;
try {
const located = await mdd.locateBytes(src);
const located = await mdd.locateBytes(path);
if (signal.aborted) return;
if (located.data) {
const blob = new Blob([new Uint8Array(located.data)]);
const url = URL.createObjectURL(blob);
trackedUrls.push(url);
img.setAttribute('src', url);
resolved.set(path, url);
return;
}
} catch (err) {
console.warn('mdd.locateBytes failed for', src, err);
console.warn('mdd.locateBytes failed for css url', path, err);
}
}
}),
);
return cssText.replace(CSS_URL_RX, (match, dq, sq, unq) => {
const path = ((dq ?? sq ?? unq ?? '') as string).trim();
const url = resolved.get(path);
return url ? `url("${url}")` : match;
});
}
function wireMdxAnchors(
container: HTMLElement,
mdds: MDDInstance[],
trackedUrls: string[],
onNavigate: ((word: string) => void) | undefined,
): void {
const anchors = Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href]'));
for (const anchor of anchors) {
const raw = anchor.getAttribute('href') ?? '';
if (SOUND_HREF_RX.test(raw)) {
if (!mdds.length) continue;
const path = raw.replace(SOUND_HREF_RX, '').trim();
if (!path) continue;
anchor.addEventListener('click', async (e) => {
e.preventDefault();
// Stop bubbling so the parent card's tap-to-expand handler doesn't fire.
e.stopPropagation();
// Speex (`.spx`) was deprecated by Xiph in 2012 in favor of Opus and
// is no longer decoded by any major browser. Skip the lookup + play
// attempt entirely and surface a toast so users with MW-style
// dictionaries understand why nothing audible happens.
if (/\.spx$/i.test(path)) {
eventDispatcher.dispatch('toast', {
type: 'warning',
timeout: 4000,
message: _(
"This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.",
),
});
return;
}
let url = anchor.getAttribute('data-mdd-resolved');
if (!url) {
for (const mdd of mdds) {
try {
const located = await mdd.locateBytes(path);
if (located.data) {
const blob = new Blob([new Uint8Array(located.data)]);
url = URL.createObjectURL(blob);
trackedUrls.push(url);
anchor.setAttribute('data-mdd-resolved', url);
break;
}
} catch (err) {
console.warn('mdd.locateBytes failed for sound', path, err);
}
}
}
if (!url) return;
const audio = new Audio(url);
audio.play().catch((err) => {
console.warn('Sound playback failed', path, err);
});
});
continue;
}
if (ENTRY_HREF_RX.test(raw)) {
if (!onNavigate) continue;
const rawTarget = raw.replace(ENTRY_HREF_RX, '');
let target: string;
try {
target = decodeURIComponent(rawTarget).trim();
} catch {
target = rawTarget.trim();
}
if (!target) continue;
anchor.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
onNavigate(target);
});
continue;
}
}
}
export const createMdictProvider = ({
@@ -104,6 +312,9 @@ export const createMdictProvider = ({
let initPromise: Promise<void> | null = null;
let initError: Error | null = null;
const trackedUrls: string[] = [];
// Loose .css files imported alongside the .mdx/.mdd. Read once at init,
// injected into every card's shadow root at lookup time.
let looseStylesheets: string[] = [];
const initOnce = async (): Promise<void> => {
if (mdx) return;
@@ -156,8 +367,26 @@ export const createMdictProvider = ({
console.warn('Failed to open MDD resource bundle', name, err);
}
}
const cssNames = dict.files.css ?? [];
const cssTexts: string[] = [];
// Resolve `url(...)` refs against the MDDs once at init: loose CSS
// files are read once and re-injected on every lookup, so doing the
// rewrite here keeps the per-lookup work minimal and the tracked
// blob URLs stable across the provider's lifetime.
const initSignal = new AbortController().signal;
for (const name of cssNames) {
try {
const cssFile = await fs.openFile(`${dict.bundleDir}/${name}`, 'Dictionaries');
const raw = await cssFile.text();
const resolved = await resolveCssUrls(raw, mddInsts, initSignal, trackedUrls);
cssTexts.push(resolved);
} catch (err) {
console.warn('Failed to read loose stylesheet', name, err);
}
}
mdx = mdxInst;
mdds = mddInsts;
looseStylesheets = cssTexts;
})().catch((err) => {
initError = err instanceof Error ? err : new Error(String(err));
initPromise = null;
@@ -189,21 +418,111 @@ export const createMdictProvider = ({
if (!mdx) return { ok: false, reason: 'error', message: 'MDX not initialized' };
try {
const result = await mdx.lookup(word);
// Follow MDict `@@@LINK=<target>` content-level redirects: the
// looked-up entry's "definition" is sometimes just the literal
// string `@@@LINK=question` pointing at the canonical headword.
// Capped at 5 hops so a malformed cycle can't deadlock the
// lookup; whatever we have at the limit is rendered as-is.
//
// Detection is intentionally loose. Real-world variants we have seen:
// `@@@LINK=question` plain
// `@@@LINK=question\r\n` Windows newline
// `@@@LINK=question\u0000` trailing NUL
// `<div>@@@LINK=question</div>` wrapped in markup by some bundles
// Use the DOM as a tolerant tag-stripper, then look for the prefix.
const redirectScratch = document.createElement('div');
const extractRedirect = (def: string | null): string | null => {
if (!def) return null;
redirectScratch.innerHTML = def;
const text = (redirectScratch.textContent ?? '').trim();
if (!text.startsWith('@@@LINK=')) return null;
// Stop at the first whitespace / control char so trailing NULs
// or carriage returns embedded in the redirect line are dropped.
const target = text
.slice('@@@LINK='.length)
.split(/[\s\u0000]/)[0]!
.trim();
return target || null;
};
let result = await mdx.lookup(word);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
for (let hop = 0; hop < 5; hop++) {
const target = extractRedirect(result.definition);
if (!target) break;
result = await mdx.lookup(target);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
}
if (!result.definition) return { ok: false, reason: 'empty' };
// Headword stays in light DOM so the app's Tailwind classes apply.
const headword = document.createElement('h1');
headword.textContent = result.keyText || word;
headword.className = 'text-lg font-bold';
ctx.container.appendChild(headword);
// Build the body off-DOM so resource resolution + link wiring can
// run before we move the tree into the shadow root. Tag the body
// with `data-dict-kind="mdict"` so app-level CSS or external
// tooling can target dict-rendered content distinct from the host's
// surrounding chrome.
const body = document.createElement('div');
body.dataset['dictKind'] = dict.kind;
body.innerHTML = result.definition;
body.className = 'mt-2 text-sm';
ctx.container.appendChild(body);
await resolveImageResources(body, mdds, ctx.signal, trackedUrls);
const rawMddStylesheets = await resolveMddStylesheets(body, mdds, ctx.signal);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
// Rewrite any `url(...)` refs inside MDD-resident stylesheets so
// their relative paths (e.g. `url(sound.png)`) point at blob URLs
// backed by the MDD instead of failing against the document base.
const mddStylesheets = await Promise.all(
rawMddStylesheets.map((css) => resolveCssUrls(css, mdds, ctx.signal, trackedUrls)),
);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
wireMdxAnchors(body, mdds, trackedUrls, ctx.onNavigate);
// Attach a shadow root to a dedicated host so the dict's CSS (loose
// .css files imported alongside + `<link>` references resolved from
// the MDD) is scoped to this card and cannot leak between dicts or
// into the app's layout. Click events still bubble naturally so the
// `sound://` / `entry://` / external-link delegation keeps working.
const shadowHost = document.createElement('div');
shadowHost.className = 'mt-2 text-sm';
ctx.container.appendChild(shadowHost);
const shadow = shadowHost.attachShadow({ mode: 'open' });
// Baseline app-level styles first (theme-aware blend rules for
// icons, etc.), then the dict's own loose CSS, then any
// MDD-resident stylesheets the MDX referenced via `<link>`.
// Cascade order matches authoring order.
const allStylesheets = [getDictStyles(), ...looseStylesheets, ...mddStylesheets];
for (const cssText of allStylesheets) {
if (!cssText) continue;
const style = document.createElement('style');
style.textContent = cssText;
shadow.appendChild(style);
}
shadow.appendChild(body);
// Hide our auto-prepended headword when the dict's own rendering
// already ships one with the same text. Many dicts put a large
// styled `<h1>` at the top of every entry; others use `<h3>`,
// `<dt>`, or a custom-tagged element (the 探春 dict uses
// `<h3 class="entry_name">`). We match in two complementary ways:
// 1. The body's very first element child (catches non-h1 leads).
// 2. Any `<h1>` anywhere in the shadow (catches dicts that
// prefix the entry with a wrapper div before the headword,
// e.g. Webster's `<div class="jumpcontent">` then h1).
const ourTitle = (headword.textContent ?? '').trim();
if (ourTitle) {
const matchesText = (el: Element) => (el.textContent ?? '').trim() === ourTitle;
const firstChild = body.firstElementChild;
const dup =
(!!firstChild && matchesText(firstChild)) ||
Array.from(shadow.querySelectorAll('h1')).some(matchesText);
if (dup) headword.remove();
}
return { ok: true, headword: result.keyText, sourceLabel: dict.name };
} catch (err) {
return {
@@ -22,6 +22,12 @@ export interface DictionaryLookupContext {
* cross-link navigation can ignore it.
*/
onNavigate?(word: string): void;
/**
* Theme hint forwarded by the shell. Providers that inject styles into a
* shadow root (MDict) use this to pick blend modes / overrides that match
* the current app theme. Optional defaults to light treatment.
*/
isDarkMode?: boolean;
}
export type DictionaryLookupOutcome =
@@ -71,6 +77,13 @@ export interface ImportedDictionary {
// MDict bundle.
mdx?: string;
mdd?: string[];
/**
* Loose `.css` files imported alongside the `.mdx`/`.mdd` (matched by
* stem at import time). Applied as scoped stylesheets inside the card's
* shadow root at lookup time, in addition to any `<link
* rel="stylesheet">` references resolved from the MDD bundle.
*/
css?: 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;
@@ -47,6 +47,19 @@ interface DictionaryStoreState {
/** Add (or revive) an imported dictionary. New entries are appended to providerOrder + enabled. */
addDictionary(dict: ImportedDictionary): void;
/**
* Patch an imported dictionary's mutable display fields (currently just
* `name`). The on-disk bundle is untouched. No-op if the id is unknown
* or refers to a deleted entry.
*/
updateDictionary(id: string, patch: { name?: string }): void;
/**
* Drop one or more existing dictionaries by id and insert `newDict` in
* the first removed entry's slot in `providerOrder`, inheriting that
* entry's enabled flag. Used by the importer when a re-imported dict
* matches an existing one (or several) by name.
*/
replaceDictionaries(oldIds: string[], newDict: ImportedDictionary): void;
/** Soft-delete an imported entry by id; remove from providerOrder + providerEnabled. */
removeDictionary(id: string): boolean;
/** Replace a subset of provider ids in providerOrder; ignores unknown ids. */
@@ -109,6 +122,71 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
});
},
updateDictionary: (id, patch) => {
set((state) => {
const idx = state.dictionaries.findIndex((d) => d.id === id);
if (idx < 0) return state;
const old = state.dictionaries[idx]!;
if (old.deletedAt) return state;
const trimmedName = patch.name?.trim();
// Reject undefined (no patch), empty (would clear the label), and
// unchanged (no-op).
if (!trimmedName || trimmedName === old.name) return state;
const dictionaries = state.dictionaries.map((d, i) =>
i === idx ? { ...d, name: trimmedName } : d,
);
return { dictionaries };
});
},
replaceDictionaries: (oldIds, newDict) => {
if (oldIds.length === 0) {
get().addDictionary(newDict);
return;
}
const oldIdSet = new Set(oldIds);
set((state) => {
// Drop all old entries (hard-remove since the disk bundles are gone)
// and append the new one. Soft-delete isn't needed: the previously
// stored entries are no longer recoverable.
const dictionaries = state.dictionaries.filter((d) => !oldIdSet.has(d.id));
dictionaries.push(newDict);
// Splice the new id into providerOrder at the first old slot. Drop
// any further old slots.
const oldOrder = state.settings.providerOrder;
const providerOrder: string[] = [];
let inserted = false;
for (const id of oldOrder) {
if (oldIdSet.has(id)) {
if (!inserted) {
providerOrder.push(newDict.id);
inserted = true;
}
} else {
providerOrder.push(id);
}
}
if (!inserted) providerOrder.push(newDict.id);
// Inherit the first old entry's enabled flag (default to !unsupported
// if the old wasn't recorded).
const firstOldId = oldIds[0]!;
const inheritedEnabled =
state.settings.providerEnabled[firstOldId] !== undefined
? state.settings.providerEnabled[firstOldId] !== false
: !newDict.unsupported;
const providerEnabled = { ...state.settings.providerEnabled };
for (const oldId of oldIds) delete providerEnabled[oldId];
providerEnabled[newDict.id] = inheritedEnabled;
return {
dictionaries,
settings: { ...state.settings, providerOrder, providerEnabled },
};
});
},
removeDictionary: (id) => {
const dict = get().dictionaries.find((d) => d.id === id);
if (!dict) return false;
+4 -1
View File
@@ -131,7 +131,10 @@ export interface AppService {
deleteFont(font: CustomFont): Promise<void>;
importImage(file?: string | File): Promise<CustomTextureInfo | null>;
deleteImage(texture: CustomTextureInfo): Promise<void>;
importDictionaries(files: SelectedFile[]): Promise<ImportDictionariesResult>;
importDictionaries(
files: SelectedFile[],
existingDictionaries?: ImportedDictionary[],
): Promise<ImportDictionariesResult>;
deleteDictionary(dict: ImportedDictionary): Promise<void>;
importBook(file: string | File, books: Book[], options?: ImportBookOptions): Promise<Book | null>;
refreshBookMetadata(book: Book): Promise<boolean>;
+20
View File
@@ -531,6 +531,26 @@ export const getFootnoteStyles = () => `
}
`;
/**
* Baseline stylesheet injected into every dictionary card's shadow root
* (alongside any loose `.css` files imported with the bundle and any
* `<link rel="stylesheet">` references resolved from the MDD).
*
* The seam exists so app-wide rules can be added in one place without
* touching the provider code. Currently it ships:
*/
export const getDictStyles = () => {
return `
a:empty {
background-color: transparent;
mix-blend-mode: multiply;
}
a img {
mix-blend-mode: multiply;
}
`;
};
const getTranslationStyles = (showSource: boolean) => `
.translation-source {
}