From 3620c6103841c3be80e4bae8a146d83d9d89eb10 Mon Sep 17 00:00:00 2001 From: loveheaven Date: Sun, 17 May 2026 11:37:26 +0800 Subject: [PATCH] feat(reader): import annotations from Moon+ Reader (.mrexpt) (#4174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(reader): import annotations from Moon+ Reader (.mrexpt) Add a new menu entry under the reader sidebar 'More' menu that lets users import highlights and notes exported from the Moon+ Reader Android app. Implementation: - utils/mrexpt.ts: parser for the .mrexpt plaintext format (entry id, NCX navPoint index b4, character offset b6, type marker, word and note). - services/annotation/providers/mrexpt.ts: convert mrexpt entries to BookNote[] using bookDoc. Locate the chapter via b4 -> toc -> spine, then TreeWalker-search the section DOM for the highlighted word with English suffix tolerance (ing/ed/s/...). Falls back to a section-level CFI when the exact word can't be located. Re-imports are deduplicated by a stable id derived from entryId. - BookMenu: add 'Import from Moon+ Reader' menu item dispatching the 'import-mrexpt' event. - Annotator: handle 'import-mrexpt' — pick the file (Web File / Tauri path), parse, convert against the live bookDoc, merge into booknotes (latest updatedAt wins), persist via saveConfig, and apply to all live views so highlights appear immediately. User feedback via toasts (importing / imported N / N unmatched / nothing new). * refactor(reader): simplify Moon+ Reader import notifications Reworks the .mrexpt import UX so it shows exactly one toast per run instead of up to two, and removes redundant intermediate notices. - Drop the intermediate "Importing N annotations…" toast. The toast system shows one toast at a time, so it merely flashed and was replaced by the result toast. - Drop the duplicate "Failed to read the selected file." toast in the read catch block; it falls through to the existing empty-content check which surfaces the same message. - Collapse the three-way result toast (already imported / N unmatched / N imported) into one: "Imported {{count}} annotations" or "No new annotations to import". - Fix a result-message bug: when every converted note was already imported and nothing was unmatched, the toast read "Imported 0 annotations." It now reports "No new annotations to import". - Pluralize the success message via i18n `count` (the previous `{{n}}` placeholder never pluralized, e.g. "Imported 1 annotations"). - Extract the dedupe/merge logic into a pure, unit-tested `mergeImportedBookNotes` helper. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(i18n): translate Moon+ Reader import strings Run i18next extraction and translate the new .mrexpt import strings across all 33 locales (340 keys). The import feature added in this PR introduced translatable strings that had not yet been extracted. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Huang Xin Co-authored-by: Claude Opus 4.7 (1M context) --- .../public/locales/ar/translation.json | 16 +- .../public/locales/bn/translation.json | 12 +- .../public/locales/bo/translation.json | 11 +- .../public/locales/de/translation.json | 12 +- .../public/locales/el/translation.json | 12 +- .../public/locales/en/translation.json | 4 +- .../public/locales/es/translation.json | 13 +- .../public/locales/fa/translation.json | 12 +- .../public/locales/fr/translation.json | 13 +- .../public/locales/he/translation.json | 13 +- .../public/locales/hi/translation.json | 12 +- .../public/locales/hu/translation.json | 12 +- .../public/locales/id/translation.json | 11 +- .../public/locales/it/translation.json | 13 +- .../public/locales/ja/translation.json | 11 +- .../public/locales/ko/translation.json | 11 +- .../public/locales/ms/translation.json | 11 +- .../public/locales/nl/translation.json | 12 +- .../public/locales/pl/translation.json | 14 +- .../public/locales/pt-BR/translation.json | 13 +- .../public/locales/pt/translation.json | 13 +- .../public/locales/ro/translation.json | 13 +- .../public/locales/ru/translation.json | 14 +- .../public/locales/si/translation.json | 12 +- .../public/locales/sl/translation.json | 14 +- .../public/locales/sv/translation.json | 12 +- .../public/locales/ta/translation.json | 12 +- .../public/locales/th/translation.json | 11 +- .../public/locales/tr/translation.json | 12 +- .../public/locales/uk/translation.json | 14 +- .../public/locales/uz/translation.json | 12 +- .../public/locales/vi/translation.json | 11 +- .../public/locales/zh-CN/translation.json | 11 +- .../public/locales/zh-TW/translation.json | 11 +- .../__tests__/services/mrexpt-import.test.ts | 67 +++ .../reader/components/annotator/Annotator.tsx | 129 ++++++ .../reader/components/sidebar/BookMenu.tsx | 5 + .../services/annotation/providers/mrexpt.ts | 383 ++++++++++++++++++ apps/readest-app/src/utils/mrexpt.ts | 162 ++++++++ 39 files changed, 1122 insertions(+), 34 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/mrexpt-import.test.ts create mode 100644 apps/readest-app/src/services/annotation/providers/mrexpt.ts create mode 100644 apps/readest-app/src/utils/mrexpt.ts diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 0bf85306..0615a53f 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1548,5 +1548,19 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "هل أنت متأكد من مسح التمييزات والملاحظات الـ{{count}}؟", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "اتركه فارغًا لاستخدام واجهة Readwise الرسمية. عيّن عنوان URL مخصصًا فقط للاتصال بخدمة مستضافة ذاتيًا ومتوافقة مع Readwise.", - "Custom URL": "عنوان URL مخصص" + "Custom URL": "عنوان URL مخصص", + "Book is not ready yet, please try again.": "الكتاب غير جاهز بعد، يرجى المحاولة مرة أخرى.", + "Select Moon+ Reader Export File": "اختر ملف تصدير Moon+ Reader", + "Failed to read the selected file.": "فشل قراءة الملف المحدد.", + "No annotations found in the file.": "لم يتم العثور على أي تعليقات في الملف.", + "Failed to import annotations.": "فشل استيراد التعليقات.", + "No annotations could be located in this book.": "تعذّر تحديد موضع أي تعليقات في هذا الكتاب.", + "Imported {{count}} annotations_zero": "لم يتم استيراد أي تعليقات", + "Imported {{count}} annotations_one": "تم استيراد تعليق واحد", + "Imported {{count}} annotations_two": "تم استيراد تعليقين", + "Imported {{count}} annotations_few": "تم استيراد {{count}} تعليقات", + "Imported {{count}} annotations_many": "تم استيراد {{count}} تعليقًا", + "Imported {{count}} annotations_other": "تم استيراد {{count}} تعليق", + "No new annotations to import": "لا توجد تعليقات جديدة للاستيراد", + "Import from Moon+ Reader": "استيراد من Moon+ Reader" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index feff091b..a3ba092f 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "আপনি কি নিশ্চিত যে সব {{count}}টি হাইলাইট ও নোট মুছে ফেলতে চান?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "অফিসিয়াল Readwise API ব্যবহার করতে ফাঁকা রাখুন। শুধুমাত্র একটি স্ব-হোস্ট করা, Readwise-সামঞ্জস্যপূর্ণ পরিষেবার জন্য একটি কাস্টম URL সেট করুন।", - "Custom URL": "কাস্টম URL" + "Custom URL": "কাস্টম URL", + "Book is not ready yet, please try again.": "বইটি এখনও প্রস্তুত নয়, অনুগ্রহ করে আবার চেষ্টা করুন।", + "Select Moon+ Reader Export File": "Moon+ Reader এক্সপোর্ট ফাইল নির্বাচন করুন", + "Failed to read the selected file.": "নির্বাচিত ফাইলটি পড়তে ব্যর্থ হয়েছে।", + "No annotations found in the file.": "ফাইলে কোনো টীকা পাওয়া যায়নি।", + "Failed to import annotations.": "টীকা ইম্পোর্ট করতে ব্যর্থ হয়েছে।", + "No annotations could be located in this book.": "এই বইয়ে কোনো টীকার অবস্থান নির্ণয় করা যায়নি।", + "Imported {{count}} annotations_one": "{{count}}টি টীকা ইম্পোর্ট করা হয়েছে", + "Imported {{count}} annotations_other": "{{count}}টি টীকা ইম্পোর্ট করা হয়েছে", + "No new annotations to import": "ইম্পোর্ট করার জন্য কোনো নতুন টীকা নেই", + "Import from Moon+ Reader": "Moon+ Reader থেকে ইম্পোর্ট করুন" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 8c665058..fa4856ec 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1413,5 +1413,14 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "གཙོ་གནད་དང་ཟིན་བྲིས་ {{count}} ཚང་མ་སུབ་པར་གཏན་འཁེལ་ལམ།", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Readwise ཡི་ངོ་མའི་API སྤྱོད་ན་སྟོང་པར་བཞག Readwise དང་མཐུན་པའི་རང་འཇོག་ཞབས་ཞུའི་ཆེད་ཁོ་ནར་སྒེར་གྱི་URL འགོད་དགོས།", - "Custom URL": "སྒེར་གྱི་URL" + "Custom URL": "སྒེར་གྱི་URL", + "Book is not ready yet, please try again.": "དཔེ་ཆ་ད་དུང་གྲ་སྒྲིག་མ་ཟིན་པས། ཡང་བསྐྱར་ཚོད་ལྟ་གནང་རོགས།", + "Select Moon+ Reader Export File": "Moon+ Reader ཕྱིར་འདྲེན་ཡིག་ཆ་འདེམས།", + "Failed to read the selected file.": "འདེམས་ཟིན་པའི་ཡིག་ཆ་ཀློག་མ་ཐུབ།", + "No annotations found in the file.": "ཡིག་ཆའི་ནང་མཆན་འགྲེལ་མ་རྙེད།", + "Failed to import annotations.": "མཆན་འགྲེལ་ནང་འདྲེན་མ་ཐུབ།", + "No annotations could be located in this book.": "དཔེ་ཆ་འདིའི་ནང་མཆན་འགྲེལ་གང་ཡང་གནས་ས་ངོས་འཛིན་མ་ཐུབ།", + "Imported {{count}} annotations_other": "མཆན་འགྲེལ་ {{count}} ནང་འདྲེན་བྱས་ཟིན།", + "No new annotations to import": "ནང་འདྲེན་བྱ་རྒྱུའི་མཆན་འགྲེལ་གསར་པ་མེད།", + "Import from Moon+ Reader": "Moon+ Reader ནས་ནང་འདྲེན།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 4d78d767..1f3e8d62 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Möchten Sie wirklich alle {{count}} Markierungen und Notizen löschen?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Leer lassen, um die offizielle Readwise-API zu verwenden. Eine benutzerdefinierte URL nur festlegen, um einen selbst gehosteten, Readwise-kompatiblen Dienst anzusprechen.", - "Custom URL": "Benutzerdefinierte URL" + "Custom URL": "Benutzerdefinierte URL", + "Book is not ready yet, please try again.": "Das Buch ist noch nicht bereit, bitte versuche es erneut.", + "Select Moon+ Reader Export File": "Moon+ Reader-Exportdatei auswählen", + "Failed to read the selected file.": "Die ausgewählte Datei konnte nicht gelesen werden.", + "No annotations found in the file.": "Keine Anmerkungen in der Datei gefunden.", + "Failed to import annotations.": "Anmerkungen konnten nicht importiert werden.", + "No annotations could be located in this book.": "In diesem Buch konnten keine Anmerkungen zugeordnet werden.", + "Imported {{count}} annotations_one": "{{count}} Anmerkung importiert", + "Imported {{count}} annotations_other": "{{count}} Anmerkungen importiert", + "No new annotations to import": "Keine neuen Anmerkungen zum Importieren", + "Import from Moon+ Reader": "Aus Moon+ Reader importieren" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 11743f7d..f77a1454 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Είστε σίγουροι ότι θέλετε να διαγράψετε και τις {{count}} επισημάνσεις και σημειώσεις;", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Αφήστε το κενό για χρήση του επίσημου Readwise API. Ορίστε προσαρμοσμένο URL μόνο για να στοχεύσετε μια αυτο-φιλοξενούμενη υπηρεσία συμβατή με το Readwise.", - "Custom URL": "Προσαρμοσμένο URL" + "Custom URL": "Προσαρμοσμένο URL", + "Book is not ready yet, please try again.": "Το βιβλίο δεν είναι ακόμη έτοιμο, δοκιμάστε ξανά.", + "Select Moon+ Reader Export File": "Επιλογή αρχείου εξαγωγής Moon+ Reader", + "Failed to read the selected file.": "Αποτυχία ανάγνωσης του επιλεγμένου αρχείου.", + "No annotations found in the file.": "Δεν βρέθηκαν σχόλια στο αρχείο.", + "Failed to import annotations.": "Αποτυχία εισαγωγής σχολίων.", + "No annotations could be located in this book.": "Δεν ήταν δυνατός ο εντοπισμός σχολίων σε αυτό το βιβλίο.", + "Imported {{count}} annotations_one": "Εισήχθη {{count}} σχόλιο", + "Imported {{count}} annotations_other": "Εισήχθησαν {{count}} σχόλια", + "No new annotations to import": "Δεν υπάρχουν νέα σχόλια για εισαγωγή", + "Import from Moon+ Reader": "Εισαγωγή από Moon+ Reader" } diff --git a/apps/readest-app/public/locales/en/translation.json b/apps/readest-app/public/locales/en/translation.json index c63486bf..1dd660de 100644 --- a/apps/readest-app/public/locales/en/translation.json +++ b/apps/readest-app/public/locales/en/translation.json @@ -57,5 +57,7 @@ "Cleared {{count}} highlights and notes._one": "Cleared {{count}} highlight and note.", "Cleared {{count}} highlights and notes._other": "Cleared {{count}} highlights and notes.", "Are you sure to clear all {{count}} highlights and notes?_one": "Are you sure to clear all {{count}} highlight and note?", - "Are you sure to clear all {{count}} highlights and notes?_other": "Are you sure to clear all {{count}} highlights and notes?" + "Are you sure to clear all {{count}} highlights and notes?_other": "Are you sure to clear all {{count}} highlights and notes?", + "Imported {{count}} annotations_one": "Imported {{count}} annotation", + "Imported {{count}} annotations_other": "Imported {{count}} annotations" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 00b6912a..5cd0db55 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1467,5 +1467,16 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "¿Seguro que quieres borrar los {{count}} resaltados y notas?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Déjalo en blanco para usar la API oficial de Readwise. Establece una URL personalizada solo para apuntar a un servicio autoalojado compatible con Readwise.", - "Custom URL": "URL personalizada" + "Custom URL": "URL personalizada", + "Book is not ready yet, please try again.": "El libro aún no está listo, inténtalo de nuevo.", + "Select Moon+ Reader Export File": "Seleccionar archivo de exportación de Moon+ Reader", + "Failed to read the selected file.": "No se pudo leer el archivo seleccionado.", + "No annotations found in the file.": "No se encontraron anotaciones en el archivo.", + "Failed to import annotations.": "No se pudieron importar las anotaciones.", + "No annotations could be located in this book.": "No se pudo ubicar ninguna anotación en este libro.", + "Imported {{count}} annotations_one": "{{count}} anotación importada", + "Imported {{count}} annotations_many": "{{count}} anotaciones importadas", + "Imported {{count}} annotations_other": "{{count}} anotaciones importadas", + "No new annotations to import": "No hay anotaciones nuevas para importar", + "Import from Moon+ Reader": "Importar desde Moon+ Reader" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 595d4a39..00593752 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "آیا مطمئنید که می‌خواهید همهٔ {{count}} برجسته و یادداشت را پاک کنید؟", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "برای استفاده از API رسمی Readwise خالی بگذارید. تنها برای اتصال به یک سرویس خودمیزبان سازگار با Readwise یک نشانی سفارشی تنظیم کنید.", - "Custom URL": "نشانی سفارشی" + "Custom URL": "نشانی سفارشی", + "Book is not ready yet, please try again.": "کتاب هنوز آماده نیست، لطفاً دوباره تلاش کنید.", + "Select Moon+ Reader Export File": "انتخاب فایل خروجی Moon+ Reader", + "Failed to read the selected file.": "خواندن فایل انتخاب‌شده ناموفق بود.", + "No annotations found in the file.": "هیچ یادداشتی در فایل یافت نشد.", + "Failed to import annotations.": "وارد کردن یادداشت‌ها ناموفق بود.", + "No annotations could be located in this book.": "مکان هیچ یادداشتی در این کتاب پیدا نشد.", + "Imported {{count}} annotations_one": "{{count}} یادداشت وارد شد", + "Imported {{count}} annotations_other": "{{count}} یادداشت وارد شد", + "No new annotations to import": "یادداشت جدیدی برای وارد کردن وجود ندارد", + "Import from Moon+ Reader": "وارد کردن از Moon+ Reader" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index e6dd4f01..da95ed81 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1467,5 +1467,16 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Voulez-vous vraiment effacer les {{count}} surlignages et notes ?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Laissez vide pour utiliser l'API officielle de Readwise. Définissez une URL personnalisée uniquement pour cibler un service auto-hébergé compatible avec Readwise.", - "Custom URL": "URL personnalisée" + "Custom URL": "URL personnalisée", + "Book is not ready yet, please try again.": "Le livre n'est pas encore prêt, veuillez réessayer.", + "Select Moon+ Reader Export File": "Sélectionner le fichier d'exportation Moon+ Reader", + "Failed to read the selected file.": "Échec de la lecture du fichier sélectionné.", + "No annotations found in the file.": "Aucune annotation trouvée dans le fichier.", + "Failed to import annotations.": "Échec de l'importation des annotations.", + "No annotations could be located in this book.": "Impossible de localiser des annotations dans ce livre.", + "Imported {{count}} annotations_one": "{{count}} annotation importée", + "Imported {{count}} annotations_many": "{{count}} annotations importées", + "Imported {{count}} annotations_other": "{{count}} annotations importées", + "No new annotations to import": "Aucune nouvelle annotation à importer", + "Import from Moon+ Reader": "Importer depuis Moon+ Reader" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index cccc4c3c..9b1bd537 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1467,5 +1467,16 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "האם לנקות את כל {{count}} ההדגשות וההערות?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "השאירו ריק כדי להשתמש ב-API הרשמי של Readwise. הגדירו כתובת URL מותאמת אישית רק כדי להפנות לשירות בעל אירוח עצמי התואם ל-Readwise.", - "Custom URL": "כתובת URL מותאמת אישית" + "Custom URL": "כתובת URL מותאמת אישית", + "Book is not ready yet, please try again.": "הספר עדיין לא מוכן, נסה שוב.", + "Select Moon+ Reader Export File": "בחר קובץ ייצוא של Moon+ Reader", + "Failed to read the selected file.": "קריאת הקובץ הנבחר נכשלה.", + "No annotations found in the file.": "לא נמצאו הערות בקובץ.", + "Failed to import annotations.": "ייבוא ההערות נכשל.", + "No annotations could be located in this book.": "לא ניתן היה לאתר הערות בספר זה.", + "Imported {{count}} annotations_one": "יובאה הערה אחת", + "Imported {{count}} annotations_two": "יובאו שתי הערות", + "Imported {{count}} annotations_other": "יובאו {{count}} הערות", + "No new annotations to import": "אין הערות חדשות לייבוא", + "Import from Moon+ Reader": "ייבוא מ-Moon+ Reader" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 162b89bc..705cabd4 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "क्या आप वाकई सभी {{count}} हाइलाइट और नोट साफ़ करना चाहते हैं?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "आधिकारिक Readwise API का उपयोग करने के लिए खाली छोड़ें। कस्टम URL केवल तभी सेट करें जब किसी स्वयं-होस्ट किए गए, Readwise-संगत सेवा को लक्षित करना हो।", - "Custom URL": "कस्टम URL" + "Custom URL": "कस्टम URL", + "Book is not ready yet, please try again.": "पुस्तक अभी तैयार नहीं है, कृपया पुनः प्रयास करें।", + "Select Moon+ Reader Export File": "Moon+ Reader निर्यात फ़ाइल चुनें", + "Failed to read the selected file.": "चयनित फ़ाइल पढ़ने में विफल।", + "No annotations found in the file.": "फ़ाइल में कोई एनोटेशन नहीं मिला।", + "Failed to import annotations.": "एनोटेशन आयात करने में विफल।", + "No annotations could be located in this book.": "इस पुस्तक में कोई एनोटेशन नहीं मिल सका।", + "Imported {{count}} annotations_one": "{{count}} एनोटेशन आयात किया गया", + "Imported {{count}} annotations_other": "{{count}} एनोटेशन आयात किए गए", + "No new annotations to import": "आयात करने के लिए कोई नया एनोटेशन नहीं", + "Import from Moon+ Reader": "Moon+ Reader से आयात करें" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 5bf9c241..32a8f286 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Biztosan törli mind a(z) {{count}} kiemelést és jegyzetet?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Hagyja üresen a hivatalos Readwise API használatához. Egyéni URL-t csak akkor adjon meg, ha saját üzemeltetésű, Readwise-kompatibilis szolgáltatást szeretne megcélozni.", - "Custom URL": "Egyéni URL" + "Custom URL": "Egyéni URL", + "Book is not ready yet, please try again.": "A könyv még nincs kész, próbáld újra.", + "Select Moon+ Reader Export File": "Moon+ Reader exportfájl kiválasztása", + "Failed to read the selected file.": "A kiválasztott fájl beolvasása sikertelen.", + "No annotations found in the file.": "Nem található jegyzet a fájlban.", + "Failed to import annotations.": "A jegyzetek importálása sikertelen.", + "No annotations could be located in this book.": "Nem sikerült jegyzeteket azonosítani ebben a könyvben.", + "Imported {{count}} annotations_one": "{{count}} jegyzet importálva", + "Imported {{count}} annotations_other": "{{count}} jegyzet importálva", + "No new annotations to import": "Nincs új importálható jegyzet", + "Import from Moon+ Reader": "Importálás Moon+ Reader programból" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index ae5f5724..24b24e53 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1413,5 +1413,14 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Yakin ingin menghapus semua {{count}} sorotan dan catatan?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Kosongkan untuk menggunakan API Readwise resmi. Setel URL khusus hanya untuk menargetkan layanan yang dihosting sendiri dan kompatibel dengan Readwise.", - "Custom URL": "URL Khusus" + "Custom URL": "URL Khusus", + "Book is not ready yet, please try again.": "Buku belum siap, silakan coba lagi.", + "Select Moon+ Reader Export File": "Pilih file ekspor Moon+ Reader", + "Failed to read the selected file.": "Gagal membaca file yang dipilih.", + "No annotations found in the file.": "Tidak ada anotasi ditemukan dalam file.", + "Failed to import annotations.": "Gagal mengimpor anotasi.", + "No annotations could be located in this book.": "Tidak ada anotasi yang dapat ditemukan di buku ini.", + "Imported {{count}} annotations_other": "{{count}} anotasi diimpor", + "No new annotations to import": "Tidak ada anotasi baru untuk diimpor", + "Import from Moon+ Reader": "Impor dari Moon+ Reader" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index e0e8c415..5ee098fb 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1467,5 +1467,16 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Vuoi davvero cancellare tutte le {{count}} evidenziazioni e note?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Lascia vuoto per usare l'API ufficiale di Readwise. Imposta un URL personalizzato solo per puntare a un servizio self-hosted compatibile con Readwise.", - "Custom URL": "URL personalizzato" + "Custom URL": "URL personalizzato", + "Book is not ready yet, please try again.": "Il libro non è ancora pronto, riprova.", + "Select Moon+ Reader Export File": "Seleziona il file di esportazione di Moon+ Reader", + "Failed to read the selected file.": "Impossibile leggere il file selezionato.", + "No annotations found in the file.": "Nessuna annotazione trovata nel file.", + "Failed to import annotations.": "Impossibile importare le annotazioni.", + "No annotations could be located in this book.": "Impossibile individuare annotazioni in questo libro.", + "Imported {{count}} annotations_one": "{{count}} annotazione importata", + "Imported {{count}} annotations_many": "{{count}} annotazioni importate", + "Imported {{count}} annotations_other": "{{count}} annotazioni importate", + "No new annotations to import": "Nessuna nuova annotazione da importare", + "Import from Moon+ Reader": "Importa da Moon+ Reader" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index b144dc26..d246226c 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1413,5 +1413,14 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "{{count}} 件のハイライトとメモをすべてクリアしてもよろしいですか?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "公式のReadwise APIを使用する場合は空白のままにします。カスタムURLは、セルフホストのReadwise互換サービスに接続する場合のみ設定してください。", - "Custom URL": "カスタムURL" + "Custom URL": "カスタムURL", + "Book is not ready yet, please try again.": "本の準備がまだできていません。もう一度お試しください。", + "Select Moon+ Reader Export File": "Moon+ Reader のエクスポートファイルを選択", + "Failed to read the selected file.": "選択したファイルを読み込めませんでした。", + "No annotations found in the file.": "ファイルに注釈が見つかりませんでした。", + "Failed to import annotations.": "注釈をインポートできませんでした。", + "No annotations could be located in this book.": "この本で注釈の位置を特定できませんでした。", + "Imported {{count}} annotations_other": "{{count}} 件の注釈をインポートしました", + "No new annotations to import": "インポートする新しい注釈はありません", + "Import from Moon+ Reader": "Moon+ Reader からインポート" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 343b2675..7195f52e 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1413,5 +1413,14 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "하이라이트 및 메모 {{count}}개를 모두 지우시겠습니까?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "공식 Readwise API를 사용하려면 비워 두세요. 사용자 지정 URL은 자체 호스팅된 Readwise 호환 서비스를 대상으로 할 때만 설정하세요.", - "Custom URL": "사용자 지정 URL" + "Custom URL": "사용자 지정 URL", + "Book is not ready yet, please try again.": "책이 아직 준비되지 않았습니다. 다시 시도해 주세요.", + "Select Moon+ Reader Export File": "Moon+ Reader 내보내기 파일 선택", + "Failed to read the selected file.": "선택한 파일을 읽지 못했습니다.", + "No annotations found in the file.": "파일에서 주석을 찾을 수 없습니다.", + "Failed to import annotations.": "주석을 가져오지 못했습니다.", + "No annotations could be located in this book.": "이 책에서 주석의 위치를 찾을 수 없습니다.", + "Imported {{count}} annotations_other": "주석 {{count}}개를 가져왔습니다", + "No new annotations to import": "가져올 새 주석이 없습니다", + "Import from Moon+ Reader": "Moon+ Reader에서 가져오기" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index a1535962..572dfdb3 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1413,5 +1413,14 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Adakah anda pasti mahu mengosongkan semua {{count}} serlahan dan nota?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Biarkan kosong untuk menggunakan API Readwise rasmi. Tetapkan URL tersuai hanya untuk menyasarkan perkhidmatan layan-diri yang serasi dengan Readwise.", - "Custom URL": "URL Tersuai" + "Custom URL": "URL Tersuai", + "Book is not ready yet, please try again.": "Buku belum sedia, sila cuba lagi.", + "Select Moon+ Reader Export File": "Pilih fail eksport Moon+ Reader", + "Failed to read the selected file.": "Gagal membaca fail yang dipilih.", + "No annotations found in the file.": "Tiada anotasi ditemui dalam fail.", + "Failed to import annotations.": "Gagal mengimport anotasi.", + "No annotations could be located in this book.": "Tiada anotasi dapat dikesan dalam buku ini.", + "Imported {{count}} annotations_other": "{{count}} anotasi diimport", + "No new annotations to import": "Tiada anotasi baharu untuk diimport", + "Import from Moon+ Reader": "Import dari Moon+ Reader" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 4ef95f32..3e1ee79e 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Weet u zeker dat u alle {{count}} markeringen en notities wilt wissen?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Laat leeg om de officiële Readwise-API te gebruiken. Stel alleen een aangepaste URL in om een zelf-gehoste, Readwise-compatibele service te benaderen.", - "Custom URL": "Aangepaste URL" + "Custom URL": "Aangepaste URL", + "Book is not ready yet, please try again.": "Het boek is nog niet gereed, probeer het opnieuw.", + "Select Moon+ Reader Export File": "Moon+ Reader-exportbestand selecteren", + "Failed to read the selected file.": "Kan het geselecteerde bestand niet lezen.", + "No annotations found in the file.": "Geen annotaties gevonden in het bestand.", + "Failed to import annotations.": "Kan annotaties niet importeren.", + "No annotations could be located in this book.": "Kan geen annotaties in dit boek vinden.", + "Imported {{count}} annotations_one": "{{count}} annotatie geïmporteerd", + "Imported {{count}} annotations_other": "{{count}} annotaties geïmporteerd", + "No new annotations to import": "Geen nieuwe annotaties om te importeren", + "Import from Moon+ Reader": "Importeren uit Moon+ Reader" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 43017364..6d56497a 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1494,5 +1494,17 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Czy na pewno chcesz wyczyścić wszystkie {{count}} zaznaczeń i notatek?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Pozostaw puste, aby używać oficjalnego API Readwise. Ustaw niestandardowy adres URL tylko po to, aby kierować ruch do samodzielnie hostowanej usługi zgodnej z Readwise.", - "Custom URL": "Niestandardowy adres URL" + "Custom URL": "Niestandardowy adres URL", + "Book is not ready yet, please try again.": "Książka nie jest jeszcze gotowa, spróbuj ponownie.", + "Select Moon+ Reader Export File": "Wybierz plik eksportu Moon+ Reader", + "Failed to read the selected file.": "Nie udało się odczytać wybranego pliku.", + "No annotations found in the file.": "Nie znaleziono adnotacji w pliku.", + "Failed to import annotations.": "Nie udało się zaimportować adnotacji.", + "No annotations could be located in this book.": "Nie udało się zlokalizować żadnych adnotacji w tej książce.", + "Imported {{count}} annotations_one": "Zaimportowano {{count}} adnotację", + "Imported {{count}} annotations_few": "Zaimportowano {{count}} adnotacje", + "Imported {{count}} annotations_many": "Zaimportowano {{count}} adnotacji", + "Imported {{count}} annotations_other": "Zaimportowano {{count}} adnotacji", + "No new annotations to import": "Brak nowych adnotacji do zaimportowania", + "Import from Moon+ Reader": "Importuj z Moon+ Reader" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index 769c33e8..f7537dc6 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1467,5 +1467,16 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Tem certeza de que deseja limpar todos os {{count}} destaques e notas?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Deixe em branco para usar a API oficial do Readwise. Defina um URL personalizado apenas para direcionar a um serviço auto-hospedado compatível com o Readwise.", - "Custom URL": "URL personalizado" + "Custom URL": "URL personalizado", + "Book is not ready yet, please try again.": "O livro ainda não está pronto, tente novamente.", + "Select Moon+ Reader Export File": "Selecionar arquivo de exportação do Moon+ Reader", + "Failed to read the selected file.": "Não foi possível ler o arquivo selecionado.", + "No annotations found in the file.": "Nenhuma anotação encontrada no arquivo.", + "Failed to import annotations.": "Não foi possível importar as anotações.", + "No annotations could be located in this book.": "Não foi possível localizar anotações neste livro.", + "Imported {{count}} annotations_one": "{{count}} anotação importada", + "Imported {{count}} annotations_many": "{{count}} anotações importadas", + "Imported {{count}} annotations_other": "{{count}} anotações importadas", + "No new annotations to import": "Não há novas anotações para importar", + "Import from Moon+ Reader": "Importar do Moon+ Reader" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 018aec27..220465db 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1467,5 +1467,16 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Tem certeza de que deseja limpar todos os {{count}} destaques e notas?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Deixe em branco para utilizar a API oficial do Readwise. Defina um URL personalizado apenas para direcionar para um serviço auto-alojado compatível com o Readwise.", - "Custom URL": "URL personalizado" + "Custom URL": "URL personalizado", + "Book is not ready yet, please try again.": "O livro ainda não está pronto, tente novamente.", + "Select Moon+ Reader Export File": "Selecionar ficheiro de exportação do Moon+ Reader", + "Failed to read the selected file.": "Não foi possível ler o ficheiro selecionado.", + "No annotations found in the file.": "Nenhuma anotação encontrada no ficheiro.", + "Failed to import annotations.": "Não foi possível importar as anotações.", + "No annotations could be located in this book.": "Não foi possível localizar anotações neste livro.", + "Imported {{count}} annotations_one": "{{count}} anotação importada", + "Imported {{count}} annotations_many": "{{count}} anotações importadas", + "Imported {{count}} annotations_other": "{{count}} anotações importadas", + "No new annotations to import": "Não há novas anotações para importar", + "Import from Moon+ Reader": "Importar do Moon+ Reader" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 6bc310d8..72bc7957 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1467,5 +1467,16 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Sigur ștergeți toate cele {{count}} de evidențieri și note?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Lăsați necompletat pentru a folosi API-ul oficial Readwise. Setați un URL personalizat doar pentru a viza un serviciu găzduit local, compatibil cu Readwise.", - "Custom URL": "URL personalizat" + "Custom URL": "URL personalizat", + "Book is not ready yet, please try again.": "Cartea nu este încă gata, încearcă din nou.", + "Select Moon+ Reader Export File": "Selectează fișierul de export Moon+ Reader", + "Failed to read the selected file.": "Citirea fișierului selectat a eșuat.", + "No annotations found in the file.": "Nu s-au găsit adnotări în fișier.", + "Failed to import annotations.": "Importul adnotărilor a eșuat.", + "No annotations could be located in this book.": "Nu s-a putut localiza nicio adnotare în această carte.", + "Imported {{count}} annotations_one": "{{count}} adnotare importată", + "Imported {{count}} annotations_few": "{{count}} adnotări importate", + "Imported {{count}} annotations_other": "{{count}} de adnotări importate", + "No new annotations to import": "Nicio adnotare nouă de importat", + "Import from Moon+ Reader": "Importă din Moon+ Reader" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 72c1598f..ad2c9151 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1494,5 +1494,17 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Очистить все {{count}} выделений и заметок?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Оставьте пустым, чтобы использовать официальный API Readwise. Указывайте собственный URL только для подключения к самостоятельно размещённому сервису, совместимому с Readwise.", - "Custom URL": "Пользовательский URL" + "Custom URL": "Пользовательский URL", + "Book is not ready yet, please try again.": "Книга ещё не готова, попробуйте снова.", + "Select Moon+ Reader Export File": "Выберите файл экспорта Moon+ Reader", + "Failed to read the selected file.": "Не удалось прочитать выбранный файл.", + "No annotations found in the file.": "В файле не найдено аннотаций.", + "Failed to import annotations.": "Не удалось импортировать аннотации.", + "No annotations could be located in this book.": "Не удалось найти аннотации в этой книге.", + "Imported {{count}} annotations_one": "Импортирована {{count}} аннотация", + "Imported {{count}} annotations_few": "Импортировано {{count}} аннотации", + "Imported {{count}} annotations_many": "Импортировано {{count}} аннотаций", + "Imported {{count}} annotations_other": "Импортировано {{count}} аннотаций", + "No new annotations to import": "Нет новых аннотаций для импорта", + "Import from Moon+ Reader": "Импорт из Moon+ Reader" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index d2c86b24..17560cb9 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "ඉස්මතු කිරීම් සහ සටහන් {{count}}ම හිස් කිරීමට අවශ්‍ය බව විශ්වාසද?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "නිල Readwise API භාවිතා කිරීමට හිස්ව තබන්න. ස්වයං-සත්කාරක, Readwise-අනුකූල සේවාවක් ඉලක්ක කිරීමට පමණක් අභිරුචි URL එකක් සකසන්න.", - "Custom URL": "අභිරුචි URL" + "Custom URL": "අභිරුචි URL", + "Book is not ready yet, please try again.": "පොත තවම සූදානම් නැත, කරුණාකර නැවත උත්සාහ කරන්න.", + "Select Moon+ Reader Export File": "Moon+ Reader නිර්යාත ගොනුව තෝරන්න", + "Failed to read the selected file.": "තෝරාගත් ගොනුව කියවීමට අසමත් විය.", + "No annotations found in the file.": "ගොනුවේ සටහන් කිසිවක් හමු නොවීය.", + "Failed to import annotations.": "සටහන් ආයාත කිරීමට අසමත් විය.", + "No annotations could be located in this book.": "මෙම පොතේ සටහන් කිසිවක් සොයාගත නොහැකි විය.", + "Imported {{count}} annotations_one": "සටහන් {{count}}ක් ආයාත කරන ලදී", + "Imported {{count}} annotations_other": "සටහන් {{count}}ක් ආයාත කරන ලදී", + "No new annotations to import": "ආයාත කිරීමට නව සටහන් නැත", + "Import from Moon+ Reader": "Moon+ Reader වෙතින් ආයාත කරන්න" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 627f345e..c0d8e8e9 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1494,5 +1494,17 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Ali res želite počistiti vseh {{count}} označb in opomb?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Pustite prazno za uporabo uradnega API-ja Readwise. URL po meri nastavite samo za ciljanje na samostojno gostovano storitev, združljivo z Readwise.", - "Custom URL": "URL po meri" + "Custom URL": "URL po meri", + "Book is not ready yet, please try again.": "Knjiga še ni pripravljena, poskusite znova.", + "Select Moon+ Reader Export File": "Izberite izvozno datoteko Moon+ Reader", + "Failed to read the selected file.": "Izbrane datoteke ni mogoče prebrati.", + "No annotations found in the file.": "V datoteki ni najdenih opomb.", + "Failed to import annotations.": "Opomb ni mogoče uvoziti.", + "No annotations could be located in this book.": "V tej knjigi ni mogoče najti nobenih opomb.", + "Imported {{count}} annotations_one": "Uvožena {{count}} opomba", + "Imported {{count}} annotations_two": "Uvoženi {{count}} opombi", + "Imported {{count}} annotations_few": "Uvožene {{count}} opombe", + "Imported {{count}} annotations_other": "Uvoženih {{count}} opomb", + "No new annotations to import": "Ni novih opomb za uvoz", + "Import from Moon+ Reader": "Uvozi iz Moon+ Reader" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index f24ada83..b423434a 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Är du säker på att du vill rensa alla {{count}} markeringar och anteckningar?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Lämna tomt för att använda det officiella Readwise-API:et. Ange en anpassad URL endast för att rikta mot en självhostad, Readwise-kompatibel tjänst.", - "Custom URL": "Anpassad URL" + "Custom URL": "Anpassad URL", + "Book is not ready yet, please try again.": "Boken är inte redo än, försök igen.", + "Select Moon+ Reader Export File": "Välj exportfil från Moon+ Reader", + "Failed to read the selected file.": "Kunde inte läsa den valda filen.", + "No annotations found in the file.": "Inga anteckningar hittades i filen.", + "Failed to import annotations.": "Kunde inte importera anteckningar.", + "No annotations could be located in this book.": "Kunde inte hitta några anteckningar i den här boken.", + "Imported {{count}} annotations_one": "{{count}} anteckning importerad", + "Imported {{count}} annotations_other": "{{count}} anteckningar importerade", + "No new annotations to import": "Inga nya anteckningar att importera", + "Import from Moon+ Reader": "Importera från Moon+ Reader" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index bb2c32ab..5b242554 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "அனைத்து {{count}} சிறப்பம்சங்கள் மற்றும் குறிப்புகளையும் அழிக்க வேண்டும் என்பது உறுதியா?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "அதிகாரப்பூர்வ Readwise API ஐப் பயன்படுத்த காலியாக விடவும். சுய-ஹோஸ்ட் செய்யப்பட்ட, Readwise-இணக்கமான சேவையை இலக்காகக் கொள்ள மட்டுமே தனிப்பயன் URL ஐ அமைக்கவும்.", - "Custom URL": "தனிப்பயன் URL" + "Custom URL": "தனிப்பயன் URL", + "Book is not ready yet, please try again.": "புத்தகம் இன்னும் தயாராக இல்லை, மீண்டும் முயற்சிக்கவும்.", + "Select Moon+ Reader Export File": "Moon+ Reader ஏற்றுமதி கோப்பைத் தேர்ந்தெடுக்கவும்", + "Failed to read the selected file.": "தேர்ந்தெடுத்த கோப்பைப் படிக்க முடியவில்லை.", + "No annotations found in the file.": "கோப்பில் எந்தக் குறிப்புகளும் இல்லை.", + "Failed to import annotations.": "குறிப்புகளை இறக்குமதி செய்ய முடியவில்லை.", + "No annotations could be located in this book.": "இந்தப் புத்தகத்தில் எந்தக் குறிப்புகளையும் கண்டறிய முடியவில்லை.", + "Imported {{count}} annotations_one": "{{count}} குறிப்பு இறக்குமதி செய்யப்பட்டது", + "Imported {{count}} annotations_other": "{{count}} குறிப்புகள் இறக்குமதி செய்யப்பட்டன", + "No new annotations to import": "இறக்குமதி செய்ய புதிய குறிப்புகள் இல்லை", + "Import from Moon+ Reader": "Moon+ Reader இலிருந்து இறக்குமதி செய்" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 818058cf..3116e4b7 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1413,5 +1413,14 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "แน่ใจหรือไม่ว่าต้องการล้างไฮไลต์และบันทึกทั้งหมด {{count}} รายการ?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "เว้นว่างไว้เพื่อใช้ API อย่างเป็นทางการของ Readwise ตั้งค่า URL ที่กำหนดเองเฉพาะเมื่อต้องการเชื่อมต่อกับบริการที่โฮสต์เองซึ่งเข้ากันได้กับ Readwise", - "Custom URL": "URL ที่กำหนดเอง" + "Custom URL": "URL ที่กำหนดเอง", + "Book is not ready yet, please try again.": "หนังสือยังไม่พร้อม โปรดลองอีกครั้ง", + "Select Moon+ Reader Export File": "เลือกไฟล์ส่งออกของ Moon+ Reader", + "Failed to read the selected file.": "อ่านไฟล์ที่เลือกไม่สำเร็จ", + "No annotations found in the file.": "ไม่พบคำอธิบายประกอบในไฟล์", + "Failed to import annotations.": "นำเข้าคำอธิบายประกอบไม่สำเร็จ", + "No annotations could be located in this book.": "ไม่พบตำแหน่งของคำอธิบายประกอบในหนังสือเล่มนี้", + "Imported {{count}} annotations_other": "นำเข้าคำอธิบายประกอบ {{count}} รายการแล้ว", + "No new annotations to import": "ไม่มีคำอธิบายประกอบใหม่ให้นำเข้า", + "Import from Moon+ Reader": "นำเข้าจาก Moon+ Reader" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index de84e721..14fa2d59 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Tüm {{count}} vurgu ve notu temizlemek istediğinizden emin misiniz?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Resmi Readwise API'sini kullanmak için boş bırakın. Özel bir URL'yi yalnızca kendi barındırdığınız, Readwise uyumlu bir hizmeti hedeflemek için ayarlayın.", - "Custom URL": "Özel URL" + "Custom URL": "Özel URL", + "Book is not ready yet, please try again.": "Kitap henüz hazır değil, lütfen tekrar deneyin.", + "Select Moon+ Reader Export File": "Moon+ Reader dışa aktarma dosyasını seçin", + "Failed to read the selected file.": "Seçilen dosya okunamadı.", + "No annotations found in the file.": "Dosyada açıklama bulunamadı.", + "Failed to import annotations.": "Açıklamalar içe aktarılamadı.", + "No annotations could be located in this book.": "Bu kitapta hiçbir açıklama konumlandırılamadı.", + "Imported {{count}} annotations_one": "{{count}} açıklama içe aktarıldı", + "Imported {{count}} annotations_other": "{{count}} açıklama içe aktarıldı", + "No new annotations to import": "İçe aktarılacak yeni açıklama yok", + "Import from Moon+ Reader": "Moon+ Reader'dan içe aktar" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index a8ffb321..7c34b2a4 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1494,5 +1494,17 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Очистити всі {{count}} виділень та нотаток?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Залиште порожнім, щоб використовувати офіційний API Readwise. Указуйте власну URL-адресу лише для підключення до самостійно розміщеної служби, сумісної з Readwise.", - "Custom URL": "Власний URL" + "Custom URL": "Власний URL", + "Book is not ready yet, please try again.": "Книга ще не готова, спробуйте ще раз.", + "Select Moon+ Reader Export File": "Виберіть файл експорту Moon+ Reader", + "Failed to read the selected file.": "Не вдалося прочитати вибраний файл.", + "No annotations found in the file.": "У файлі не знайдено анотацій.", + "Failed to import annotations.": "Не вдалося імпортувати анотації.", + "No annotations could be located in this book.": "Не вдалося знайти анотації в цій книзі.", + "Imported {{count}} annotations_one": "Імпортовано {{count}} анотацію", + "Imported {{count}} annotations_few": "Імпортовано {{count}} анотації", + "Imported {{count}} annotations_many": "Імпортовано {{count}} анотацій", + "Imported {{count}} annotations_other": "Імпортовано {{count}} анотацій", + "No new annotations to import": "Немає нових анотацій для імпорту", + "Import from Moon+ Reader": "Імпорт із Moon+ Reader" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 29ffe883..d02dfc5b 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1440,5 +1440,15 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Barcha {{count}} ta belgilash va eslatmani tozalamoqchimisiz?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Rasmiy Readwise API'sidan foydalanish uchun bo'sh qoldiring. Maxsus URL manzilini faqat o'zingiz joylashtirgan, Readwise bilan mos xizmatga ulanish uchun belgilang.", - "Custom URL": "Maxsus URL" + "Custom URL": "Maxsus URL", + "Book is not ready yet, please try again.": "Kitob hali tayyor emas, qaytadan urinib koʻring.", + "Select Moon+ Reader Export File": "Moon+ Reader eksport faylini tanlang", + "Failed to read the selected file.": "Tanlangan faylni oʻqib boʻlmadi.", + "No annotations found in the file.": "Faylda hech qanday izoh topilmadi.", + "Failed to import annotations.": "Izohlarni import qilib boʻlmadi.", + "No annotations could be located in this book.": "Bu kitobda hech qanday izohni aniqlab boʻlmadi.", + "Imported {{count}} annotations_one": "{{count}} ta izoh import qilindi", + "Imported {{count}} annotations_other": "{{count}} ta izoh import qilindi", + "No new annotations to import": "Import qilish uchun yangi izohlar yoʻq", + "Import from Moon+ Reader": "Moon+ Reader’dan import qilish" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index a71b2b87..d1256fcb 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1413,5 +1413,14 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "Bạn có chắc muốn xóa tất cả {{count}} đánh dấu và ghi chú không?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "Để trống để dùng API Readwise chính thức. Chỉ đặt URL tùy chỉnh để trỏ đến dịch vụ tự lưu trữ tương thích với Readwise.", - "Custom URL": "URL tùy chỉnh" + "Custom URL": "URL tùy chỉnh", + "Book is not ready yet, please try again.": "Sách chưa sẵn sàng, vui lòng thử lại.", + "Select Moon+ Reader Export File": "Chọn tệp xuất của Moon+ Reader", + "Failed to read the selected file.": "Không thể đọc tệp đã chọn.", + "No annotations found in the file.": "Không tìm thấy chú thích nào trong tệp.", + "Failed to import annotations.": "Không thể nhập chú thích.", + "No annotations could be located in this book.": "Không thể xác định vị trí chú thích nào trong cuốn sách này.", + "Imported {{count}} annotations_other": "Đã nhập {{count}} chú thích", + "No new annotations to import": "Không có chú thích mới để nhập", + "Import from Moon+ Reader": "Nhập từ Moon+ Reader" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index d1eb4cc5..130db9a8 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1413,5 +1413,14 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "确定要清除全部 {{count}} 个高亮和笔记吗?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "留空以使用官方 Readwise API。仅在需要连接自托管的 Readwise 兼容服务时才设置自定义 URL。", - "Custom URL": "自定义 URL" + "Custom URL": "自定义 URL", + "Book is not ready yet, please try again.": "书籍尚未就绪,请重试。", + "Select Moon+ Reader Export File": "选择 Moon+ Reader 导出文件", + "Failed to read the selected file.": "无法读取所选文件。", + "No annotations found in the file.": "文件中未找到注释。", + "Failed to import annotations.": "导入注释失败。", + "No annotations could be located in this book.": "无法在本书中定位任何注释。", + "Imported {{count}} annotations_other": "已导入 {{count}} 条注释", + "No new annotations to import": "没有可导入的新注释", + "Import from Moon+ Reader": "从 Moon+ Reader 导入" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index af8fe418..192be8f3 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1413,5 +1413,14 @@ "Are you sure to clear all {{count}} highlights and notes?_other": "確定要清除全部 {{count}} 個標記和筆記嗎?", "Discord": "Discord", "Leave blank to use the official Readwise API. Set a custom URL only to target a self-hosted, Readwise-compatible service.": "留空以使用官方 Readwise API。僅在需要連接自架的 Readwise 相容服務時才設定自訂 URL。", - "Custom URL": "自訂 URL" + "Custom URL": "自訂 URL", + "Book is not ready yet, please try again.": "書籍尚未就緒,請重試。", + "Select Moon+ Reader Export File": "選擇 Moon+ Reader 匯出檔案", + "Failed to read the selected file.": "無法讀取所選檔案。", + "No annotations found in the file.": "檔案中未找到註解。", + "Failed to import annotations.": "匯入註解失敗。", + "No annotations could be located in this book.": "無法在本書中定位任何註解。", + "Imported {{count}} annotations_other": "已匯入 {{count}} 條註解", + "No new annotations to import": "沒有可匯入的新註解", + "Import from Moon+ Reader": "從 Moon+ Reader 匯入" } diff --git a/apps/readest-app/src/__tests__/services/mrexpt-import.test.ts b/apps/readest-app/src/__tests__/services/mrexpt-import.test.ts new file mode 100644 index 00000000..b2df0662 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/mrexpt-import.test.ts @@ -0,0 +1,67 @@ +import { describe, test, expect } from 'vitest'; +import type { BookNote } from '@/types/book'; +import { mergeImportedBookNotes } from '@/services/annotation/providers/mrexpt'; + +const makeNote = (overrides: Partial = {}): BookNote => ({ + id: 'mrexpt-1', + type: 'annotation', + cfi: 'epubcfi(/6/4!/4)', + text: 'hello', + style: 'highlight', + color: 'yellow', + note: '', + createdAt: 1000, + updatedAt: 1000, + ...overrides, +}); + +describe('mergeImportedBookNotes', () => { + test('adds notes that do not yet exist', () => { + const result = mergeImportedBookNotes([], [makeNote()]); + expect(result.added).toBe(1); + expect(result.updated).toBe(0); + expect(result.applied).toHaveLength(1); + expect(result.merged).toHaveLength(1); + }); + + test('skips notes that were already imported unchanged', () => { + const existing = [makeNote()]; + const result = mergeImportedBookNotes(existing, [makeNote()]); + expect(result.added).toBe(0); + expect(result.updated).toBe(0); + expect(result.applied).toHaveLength(0); + expect(result.merged).toHaveLength(1); + }); + + test('reports zero changes when every incoming note is a duplicate', () => { + // Regression: a re-import of an unchanged file must surface as + // "nothing new" (added + updated === 0), never as "Imported 0". + const existing = [makeNote({ id: 'a' }), makeNote({ id: 'b' })]; + const incoming = [makeNote({ id: 'a' }), makeNote({ id: 'b' })]; + const result = mergeImportedBookNotes(existing, incoming); + expect(result.added + result.updated).toBe(0); + }); + + test('resurrects a soft-deleted note', () => { + const existing = [makeNote({ deletedAt: 5000 })]; + const result = mergeImportedBookNotes(existing, [makeNote()]); + expect(result.added).toBe(1); + expect(result.merged[0]!.deletedAt).toBeNull(); + expect(result.applied).toHaveLength(1); + }); + + test('updates an existing note when the incoming copy is newer', () => { + const existing = [makeNote({ note: 'old', updatedAt: 1000 })]; + const result = mergeImportedBookNotes(existing, [makeNote({ note: 'new', updatedAt: 2000 })]); + expect(result.updated).toBe(1); + expect(result.added).toBe(0); + expect(result.merged[0]!.note).toBe('new'); + }); + + test('preserves unrelated existing notes', () => { + const existing = [makeNote({ id: 'keep', type: 'bookmark' })]; + const result = mergeImportedBookNotes(existing, [makeNote({ id: 'new' })]); + expect(result.merged).toHaveLength(2); + expect(result.merged.some((n) => n.id === 'keep')).toBe(true); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index bc30e543..cef0cbfd 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -52,6 +52,12 @@ import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRu import ExportMarkdownDialog from './ExportMarkdownDialog'; import Alert from '@/components/Alert'; import ModalPortal from '@/components/ModalPortal'; +import { useFileSelector } from '@/hooks/useFileSelector'; +import { parseMrexpt } from '@/utils/mrexpt'; +import { + convertMrexptEntriesToBookNotes, + mergeImportedBookNotes, +} from '@/services/annotation/providers/mrexpt'; const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const _ = useTranslation(); @@ -65,6 +71,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const { clearBooknotesNav } = useSidebarStore(); const { listenToNativeTouchEvents } = useDeviceControlStore(); const { loadCustomDictionaries } = useCustomDictionaryStore(); + const { selectFiles } = useFileSelector(appService, _); useNotesSync(bookKey); useReadwiseSync(bookKey); @@ -484,9 +491,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { useEffect(() => { eventDispatcher.on('export-annotations', handleExportMarkdown); eventDispatcher.on('clear-annotations', handleClearAnnotations); + eventDispatcher.on('import-mrexpt', handleImportMrexpt); return () => { eventDispatcher.off('export-annotations', handleExportMarkdown); eventDispatcher.off('clear-annotations', handleClearAnnotations); + eventDispatcher.off('import-mrexpt', handleImportMrexpt); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -859,6 +868,126 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { [selection?.text], ); + const handleImportMrexpt = async (event: CustomEvent) => { + const { bookKey: importBookKey } = event.detail; + if (bookKey !== importBookKey) return; + + const { bookDoc } = bookData; + if (!bookDoc) { + eventDispatcher.dispatch('toast', { + type: 'warning', + message: _('Book is not ready yet, please try again.'), + timeout: 2000, + }); + return; + } + + // Pick the .mrexpt file. + const result = await selectFiles({ + type: 'generic', + accept: '.mrexpt,text/plain', + extensions: ['mrexpt', 'txt'], + multiple: false, + dialogTitle: _('Select Moon+ Reader Export File'), + }); + if (result.error || result.files.length === 0) return; + const selectedFile = result.files[0]!; + + // Read the file content as text on both Web (File) and Tauri (path). + let content = ''; + try { + if (selectedFile.file) { + content = await selectedFile.file.text(); + } else if (selectedFile.path) { + content = (await appService?.readFile(selectedFile.path, 'None', 'text')) as string; + } + } catch (e) { + console.warn('Failed to read mrexpt file:', e); + } + + if (!content) { + eventDispatcher.dispatch('toast', { + type: 'warning', + message: _('Failed to read the selected file.'), + timeout: 2000, + }); + return; + } + + const entries = parseMrexpt(content); + if (entries.length === 0) { + eventDispatcher.dispatch('toast', { + type: 'info', + message: _('No annotations found in the file.'), + timeout: 2000, + }); + return; + } + + let conversion; + try { + conversion = await convertMrexptEntriesToBookNotes(entries, bookDoc, { + highlightStyle: settings.globalReadSettings.highlightStyle, + highlightColor: + settings.globalReadSettings.highlightStyles[settings.globalReadSettings.highlightStyle], + }); + } catch (e) { + console.warn('Failed to convert mrexpt entries:', e); + eventDispatcher.dispatch('toast', { + type: 'warning', + message: _('Failed to import annotations.'), + timeout: 3000, + }); + return; + } + + if (conversion.notes.length === 0) { + eventDispatcher.dispatch('toast', { + type: 'info', + message: _('No annotations could be located in this book.'), + timeout: 2500, + }); + return; + } + + // Merge into the current book config, deduplicating by note id and + // preferring the latest updatedAt for any conflicting entries. + const config = getConfig(bookKey)!; + const { merged, applied, added, updated } = mergeImportedBookNotes( + config.booknotes ?? [], + conversion.notes, + ); + const updatedConfig = updateBooknotes(bookKey, merged); + if (updatedConfig) { + saveConfig(envConfig, bookKey, updatedConfig, settings); + } + + // Apply imported (or resurrected) annotations to all live views so + // they appear immediately. We only re-draw the notes that actually + // changed in this round, otherwise duplicate addAnnotation calls + // can confuse the overlay layer. + const views = getViewsById(bookKey.split('-')[0]!); + for (const note of applied) { + try { + views.forEach((v) => v?.addAnnotation(note)); + } catch (err) { + console.warn('Failed to add imported annotation', { note, err }); + } + } + + // A single result toast: the count if anything changed, otherwise a + // plain "nothing new" hint for a repeated import of the same file. + const imported = added + updated; + eventDispatcher.dispatch('toast', { + type: 'info', + message: + imported > 0 + ? _('Imported {{count}} annotations', { count: imported }) + : _('No new annotations to import'), + timeout: 2500, + }); + }; + const handleExportMarkdown = async (event: CustomEvent) => { const { bookKey: exportBookKey } = event.detail; if (bookKey !== exportBookKey) return; diff --git a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx index 11851282..9a5de248 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx @@ -71,6 +71,10 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) eventDispatcher.dispatch('export-annotations', { bookKey: sideBarBookKey }); setIsDropdownOpen?.(false); }; + const handleImportFromMoonReader = () => { + eventDispatcher.dispatch('import-mrexpt', { bookKey: sideBarBookKey }); + setIsDropdownOpen?.(false); + }; const handleToggleSortTOC = () => { setIsSortedTOC((prev) => !prev); setIsDropdownOpen?.(false); @@ -195,6 +199,7 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) + { + const byId = new Map(); + for (const note of existing) byId.set(note.id, note); + + const applied: BookNote[] = []; + let added = 0; + let updated = 0; + + for (const note of incoming) { + const prev = byId.get(note.id); + if (!prev) { + byId.set(note.id, note); + added += 1; + applied.push(note); + } else if (prev.deletedAt) { + const resurrected: BookNote = { + ...prev, + ...note, + deletedAt: null, + updatedAt: Date.now(), + }; + byId.set(note.id, resurrected); + added += 1; + applied.push(resurrected); + } else if (prev.updatedAt < note.updatedAt) { + const merged: BookNote = { ...prev, ...note }; + byId.set(note.id, merged); + updated += 1; + applied.push(merged); + } + } + + return { merged: Array.from(byId.values()), applied, added, updated }; +}; + +export interface MrexptConversionOptions { + /** + * Color used for plain highlights (Moon+ Reader's "highlight" color + * doesn't map cleanly to Readest, so we pick a default). + */ + highlightColor?: HighlightColor; + /** Highlight style applied to all generated notes. */ + highlightStyle?: HighlightStyle; + /** + * Optional CFI factory. When provided, will be used in preference to + * the built-in best-effort CFI generation. The factory receives the + * spine index, section, document, and matched range. + */ + cfiFactory?: ( + spineIndex: number, + section: SectionItem, + doc: Document, + range: Range, + ) => string | undefined; +} + +/** + * Build a fallback CFI string for a given spine index. Returns the section's + * own CFI (form: `epubcfi(/6/4!)`) which foliate-js can resolve to the start + * of the chapter. We deliberately don't try to invent an in-document path + * like `/4/2/1:0` because that path is rarely valid for a real book and + * would make `view.goTo()` fail to navigate. + */ +const buildFallbackSectionCfi = (spineIndex: number, section: SectionItem | undefined): string => { + if (section?.cfi) return section.cfi; + const step = 2 * (spineIndex + 1); + return `epubcfi(/6/${step}!)`; +}; + +/** + * Build a real, navigable CFI by combining the section's spine-step CFI + * with the in-section path produced from the matched Range. This mirrors + * what foliate-js's view.getCFI(index, range) does internally: + * CFI.joinIndir(sectionCfi, CFI.fromRange(range)) + * If anything goes wrong we fall back to the chapter-start CFI so the + * note still lands on the right chapter, just not the exact word. + */ +const buildRangeCfi = ( + spineIndex: number, + section: SectionItem, + doc: Document, + range: Range, + factory?: MrexptConversionOptions['cfiFactory'], +): string => { + const factoryResult = factory?.(spineIndex, section, doc, range); + if (factoryResult) return factoryResult; + if (section.cfi) { + try { + const rangeCfi = CFI.fromRange(range); + if (rangeCfi) return CFI.joinIndir(section.cfi, rangeCfi); + } catch (e) { + console.warn('Failed to build range CFI for mrexpt entry:', e); + } + } + return buildFallbackSectionCfi(spineIndex, section); +}; + +/** + * Resolve a TOC href to its 0-based spine index by looking up the + * corresponding SectionItem. + * + * BookDoc.splitTOCHref returns [sectionId, fragmentId?] where sectionId is + * the path-resolved manifest item href (matching SectionItem.id). + */ +const buildHrefToSpineIndex = (bookDoc: BookDoc): Map => { + const map = new Map(); + const sections = bookDoc.sections ?? []; + sections.forEach((section, idx) => { + if (section.id) map.set(section.id, idx); + if (section.href) map.set(section.href, idx); + }); + return map; +}; + +/** + * Build a mapping from NCX navPoint 0-based index to spine index using + * BookDoc.toc. The mrexpt format encodes the chapter as the navPoint index + * (field b4), which corresponds to the in-document order of TOC items. + */ +const buildNavpointToSpine = (bookDoc: BookDoc): number[] => { + const result: number[] = []; + const hrefToSpine = buildHrefToSpineIndex(bookDoc); + const flat = collectAllTocItems(bookDoc.toc ?? []); + for (const item of flat) { + if (!item.href) { + result.push(-1); + continue; + } + const [sectionId] = bookDoc.splitTOCHref(item.href) as [string | undefined]; + let spineIdx = -1; + if (sectionId !== undefined) { + spineIdx = hrefToSpine.get(sectionId) ?? -1; + } + if (spineIdx < 0) { + // Try a basename match (some books store relative paths in TOC). + const candidate = sectionId ?? item.href; + const basename = candidate.split('/').pop() ?? candidate; + const fallback = (bookDoc.sections ?? []).findIndex((s) => { + const sid = s.id || s.href || ''; + return sid.endsWith(`/${basename}`) || sid === basename; + }); + spineIdx = fallback; + } + result.push(spineIdx); + } + return result; +}; + +/** + * Locate the first occurrence of `word` in a Document and return a Range + * covering it. The match is case-insensitive; for ASCII words we keep a + * trailing word boundary (so `cat` doesn't bleed into `category`) and a + * tolerant leading boundary that also accepts a common English prefix — + * this way Moon+ Reader entries like `happy` still locate words such as + * `unhappy` or `dishappy` in the body text. + * + * For non-ASCII text (e.g. CJK) we fall back to a plain case-insensitive + * substring search since regex word boundaries don't apply. + */ +const findWordRange = (doc: Document, word: string): Range | null => { + if (!word) return null; + const body = doc.body; + if (!body) return null; + + const isAsciiWord = /^[A-Za-z][A-Za-z'-]*$/.test(word); + const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Common English prefixes that frequently attach to standalone words. + // Order matters when patterns overlap (longest first) so the regex + // engine prefers the longer, more specific prefix when both could + // match (e.g. "under" before "un"). + const prefixGroup = + '(?:under|over|trans|inter|super|extra|counter|semi|anti|pre|mis|non|dis|sub|out|up|re|un|in|im|il|ir|en|em)'; + // Common English suffixes (kept from the original implementation, plus a + // few comparative/adverbial forms typical for adjectives). + const suffixGroup = '(?:ly|y|ing|ed|d|es|s|er|est|ness|ment|ful|less|able|ible)'; + const pattern = isAsciiWord + ? // Left side: either a real word boundary OR a known prefix that itself + // sits at a word boundary. Right side: still requires a word boundary, + // so we won't match the target inside an unrelated longer word. + new RegExp(`(?:\\b|\\b${prefixGroup})${escaped}${suffixGroup}?\\b`, 'i') + : new RegExp(escaped, 'i'); + + const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT); + let node = walker.nextNode() as Text | null; + while (node) { + const text = node.data; + if (text) { + const match = pattern.exec(text); + if (match) { + // Locate the actual start of `word` inside the match (the regex + // also captures any leading prefix, so match.index points to the + // prefix start). Highlight the original word, not the prefix. + const wordStart = match[0].toLowerCase().lastIndexOf(word.toLowerCase()); + const start = wordStart >= 0 ? match.index + wordStart : match.index; + const end = wordStart >= 0 ? start + word.length : match.index + match[0].length; + const range = doc.createRange(); + range.setStart(node, start); + range.setEnd(node, end); + return range; + } + } + node = walker.nextNode() as Text | null; + } + return null; +}; + +const dedupeEntries = (entries: MrexptEntry[]): MrexptEntry[] => { + const seen = new Set(); + const out: MrexptEntry[] = []; + for (const entry of entries) { + const key = `${entry.word.toLowerCase()}|${entry.note}|${entry.b4}|${entry.b6}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(entry); + } + return out; +}; + +const stableNoteId = (entry: MrexptEntry): string => { + // Prefer a stable id derived from the entry so re-imports merge correctly. + if (entry.entryId) return `mrexpt-${entry.entryId}`; + return `mrexpt-${uniqueId()}`; +}; + +/** + * Convert mrexpt entries to BookNote objects, locating each highlight inside + * the BookDoc to obtain a real EPUB CFI. When the exact word cannot be found + * we still emit a note pointing at the start of the chapter so the user + * sees the import in the right place. + */ +export const convertMrexptEntriesToBookNotes = async ( + entries: MrexptEntry[], + bookDoc: BookDoc, + options: MrexptConversionOptions = {}, +): Promise => { + const sections = bookDoc.sections ?? []; + const navpointToSpine = buildNavpointToSpine(bookDoc); + const highlightStyle = options.highlightStyle ?? 'highlight'; + const highlightColor = options.highlightColor ?? 'yellow'; + + const unique = dedupeEntries(entries); + const notes: BookNote[] = []; + let unmatched = 0; + const now = Date.now(); + + // Cache section documents so we don't reparse them per word. + const docCache = new Map(); + const loadSectionDoc = async (idx: number): Promise => { + if (docCache.has(idx)) return docCache.get(idx) ?? null; + const section = sections[idx]; + if (!section?.createDocument) { + docCache.set(idx, null); + return null; + } + try { + const doc = await section.createDocument(); + docCache.set(idx, doc); + return doc; + } catch { + docCache.set(idx, null); + return null; + } + }; + + for (let i = 0; i < unique.length; i++) { + const entry = unique[i]!; + const createdAt = entry.timestamp || now + i; + + let resolvedSpineIdx = -1; + let resolvedRange: Range | null = null; + let resolvedDoc: Document | null = null; + + // Strategy 1: use b4 (navPoint index) -> spine index mapping. + if (entry.b4 >= 0 && entry.b4 < navpointToSpine.length) { + const spineIdx = navpointToSpine[entry.b4]!; + if (spineIdx >= 0 && spineIdx < sections.length) { + const doc = await loadSectionDoc(spineIdx); + if (doc) { + const range = findWordRange(doc, entry.word); + if (range) { + resolvedSpineIdx = spineIdx; + resolvedRange = range; + resolvedDoc = doc; + } else { + // Section located but word not found — still anchor at chapter. + resolvedSpineIdx = spineIdx; + } + } + } + } + + // Strategy 2: scan all sections in order if we still don't have a hit. + if (!resolvedRange) { + for (let s = 0; s < sections.length; s++) { + if (s === resolvedSpineIdx) continue; + const doc = await loadSectionDoc(s); + if (!doc) continue; + const range = findWordRange(doc, entry.word); + if (range) { + resolvedSpineIdx = s; + resolvedRange = range; + resolvedDoc = doc; + break; + } + } + } + + if (resolvedSpineIdx < 0) { + unmatched += 1; + continue; + } + + const section = sections[resolvedSpineIdx]!; + const cfi = + resolvedRange && resolvedDoc + ? buildRangeCfi(resolvedSpineIdx, section, resolvedDoc, resolvedRange, options.cfiFactory) + : buildFallbackSectionCfi(resolvedSpineIdx, section); + + if (!resolvedRange) unmatched += 1; + + const note: BookNote = { + id: stableNoteId(entry), + type: 'annotation', + cfi, + text: entry.word, + style: highlightStyle, + color: highlightColor, + note: entry.note, + createdAt, + updatedAt: createdAt, + }; + notes.push(note); + } + + return { notes, unmatched, total: unique.length }; +}; diff --git a/apps/readest-app/src/utils/mrexpt.ts b/apps/readest-app/src/utils/mrexpt.ts new file mode 100644 index 00000000..72f46bbf --- /dev/null +++ b/apps/readest-app/src/utils/mrexpt.ts @@ -0,0 +1,162 @@ +/** + * Moon+ Reader (.mrexpt) export file parser. + * + * The .mrexpt format is a plaintext file produced by the Android app + * "Moon+ Reader". Each entry encodes a highlight or annotation captured by + * the user while reading. The same logic was originally implemented in + * Python (see MultilingualWord/extract_words_from_epub.py) and is ported + * here so Readest can import the entries as BookNotes. + * + * File layout: + * line 0: numeric file header + * line 1: "indent:true|false" + * line 2: "trim:true|false" + * then a sequence of entries separated by a single line containing "#": + * line 0: entry id + * line 1: book title + * line 2: original-case file path + * line 3: lowercase file path + * line 4: b4 — NCX navPoint 0-based index (chapter locator) + * line 5: b5 — paragraph offset high bits (usually 0) + * line 6: b6 — character offset within the document + * line 7: word character length + * line 8: color/marker value (-28160 = pure highlight, others = note color) + * line 9: timestamp (ms) + * line 10: empty + * line 11: empty (pure highlight) or note text (with note) + * line 12: the highlighted word/phrase + * line 13: type marker — "1" pure highlight, "0" with note + * line 14-15: padding zeroes + */ + +export interface MrexptEntry { + /** Highlighted text. */ + word: string; + /** Optional user note attached to the highlight. */ + note: string; + /** NCX navPoint 0-based index (chapter locator). */ + b4: number; + /** Paragraph offset high bits, usually 0. */ + b5: number; + /** Character offset inside the document. */ + b6: number; + /** Word character length as recorded by Moon+ Reader. */ + wordLength: number; + /** Creation timestamp (ms). */ + timestamp: number; + /** Book title as embedded in the entry. */ + bookTitle: string; + /** File path as embedded in the entry. */ + bookPath: string; + /** Original entry id from the file. */ + entryId: string; + /** Whether the entry has an attached note (type marker "0"). */ + hasNote: boolean; +} + +const safeParseInt = (value: string | undefined): number => { + if (!value) return 0; + const trimmed = value.trim(); + if (!trimmed) return 0; + // allow leading minus + const numeric = trimmed.replace(/^-/, ''); + if (!/^\d+$/.test(numeric)) return 0; + const parsed = parseInt(trimmed, 10); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const isNumericLine = (value: string): boolean => { + const trimmed = value.trim(); + if (!trimmed) return false; + return /^-?\d+$/.test(trimmed); +}; + +/** Parse the raw text content of a .mrexpt file into a list of entries. */ +export const parseMrexpt = (content: string): MrexptEntry[] => { + // Normalize line endings, then split on a line containing exactly "#". + // We deliberately use "\n#\n" to mirror the Python implementation. + const normalized = content.replace(/\r\n?/g, '\n'); + const rawEntries = normalized.split('\n#\n'); + + const entries: MrexptEntry[] = []; + + for (let i = 0; i < rawEntries.length; i++) { + let lines = rawEntries[i]!.split('\n'); + + if (i === 0) { + // First block contains the 3-line file header before the first entry. + if (lines.length >= 3 && lines[1]!.startsWith('indent:')) { + lines = lines.slice(3); + } else { + continue; + } + } + + if (lines.length < 13) continue; + + const entryId = (lines[0] ?? '').trim(); + const bookTitle = (lines[1] ?? '').trim(); + const bookPath = (lines[2] ?? '').trim(); + const b4 = safeParseInt(lines[4]); + const b5 = safeParseInt(lines[5]); + const b6 = safeParseInt(lines[6]); + const wordLength = safeParseInt(lines[7]); + const timestamp = safeParseInt(lines[9]); + + let word = ''; + let note = ''; + let hasNote = false; + + if (lines.length >= 14) { + const typeMarker = (lines[13] ?? '').trim(); + if (typeMarker === '0') { + // Annotation: note in line 11, word in line 12. + note = (lines[11] ?? '').trim(); + word = (lines[12] ?? '').trim(); + hasNote = true; + } else if (typeMarker === '1') { + // Pure highlight: line 11 empty, word in line 12. + word = (lines[12] ?? '').trim(); + } else { + // Unknown marker — fall back to the first non-numeric line after 10. + for (let idx = 10; idx < Math.min(lines.length, 15); idx++) { + const candidate = (lines[idx] ?? '').trim(); + if (candidate && !isNumericLine(candidate)) { + word = candidate; + break; + } + } + } + } else { + // Older entries: try line 12 then a non-numeric scan. + word = (lines[12] ?? '').trim(); + if (!word) { + for (let idx = 10; idx < lines.length; idx++) { + const candidate = (lines[idx] ?? '').trim(); + if (candidate && !isNumericLine(candidate)) { + word = candidate; + break; + } + } + } + } + + if (!word) continue; + + entries.push({ + word, + note, + b4, + b5, + b6, + wordLength, + timestamp, + bookTitle, + bookPath, + entryId, + hasNote, + }); + } + + return entries; +};