diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 05b23348..2e08c52b 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -805,5 +805,24 @@ "Count": "العدد", "Start Page": "الصفحة الأولى", "Search in OPDS Catalog...": "البحث في كتالوج OPDS...", - "Please log in to use advanced TTS features": "يرجى تسجيل الدخول لاستخدام ميزات تحويل النص إلى كلام المتقدمة." + "Please log in to use advanced TTS features": "يرجى تسجيل الدخول لاستخدام ميزات تحويل النص إلى كلام المتقدمة.", + "Word limit of 30 words exceeded.": "تم تجاوز حد الكلمات البالغ 30 كلمة.", + "Proofread": "التدقيق اللغوي", + "Current selection": "النص المحدد الحالي", + "All occurrences in this book": "جميع الحالات في هذا الكتاب", + "All occurrences in your library": "جميع الحالات في مكتبتك", + "Selected text:": "النص المحدد:", + "Replace with:": "استبدال بـ:", + "Enter text...": "أدخل النص...", + "Case sensitive:": "حساس لحالة الأحرف:", + "Scope:": "النطاق:", + "Selection": "التحديد", + "Library": "المكتبة", + "Yes": "نعم", + "No": "لا", + "Proofread Replacement Rules": "قواعد استبدال التدقيق اللغوي", + "Selected Text Rules": "قواعد النص المحدد", + "No selected text replacement rules": "لا توجد قواعد استبدال نص محدد", + "Book Specific Rules": "قواعد خاصة بالكتاب", + "No book-level replacement rules": "لا توجد قواعد استبدال على مستوى الكتاب" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index bc8fc609..1a8817b9 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -765,5 +765,24 @@ "Count": "গণনা", "Start Page": "শুরু পৃষ্ঠা", "Search in OPDS Catalog...": "OPDS ক্যাটালগে অনুসন্ধান করুন...", - "Please log in to use advanced TTS features": "উন্নত TTS বৈশিষ্ট্যগুলি ব্যবহার করতে লগইন করুন।" + "Please log in to use advanced TTS features": "উন্নত TTS বৈশিষ্ট্যগুলি ব্যবহার করতে লগইন করুন।", + "Word limit of 30 words exceeded.": "৩০ শব্দের শব্দসীমা অতিক্রম হয়েছে।", + "Proofread": "প্রুফরিড", + "Current selection": "বর্তমান নির্বাচন", + "All occurrences in this book": "এই বইয়ে সমস্ত ঘটনা", + "All occurrences in your library": "আপনার লাইব্রেরির সমস্ত ঘটনা", + "Selected text:": "নির্বাচিত টেক্সট:", + "Replace with:": "এর সাথে প্রতিস্থাপন করুন:", + "Enter text...": "টেক্সট লিখুন...", + "Case sensitive:": "কেস সংবেদনশীল:", + "Scope:": "পরিসর:", + "Selection": "নির্বাচন", + "Library": "লাইব্রেরি", + "Yes": "হ্যাঁ", + "No": "না", + "Proofread Replacement Rules": "প্রুফরিড বিকল্প নিয়ম", + "Selected Text Rules": "নির্বাচিত টেক্সট নিয়ম", + "No selected text replacement rules": "কোনও নির্বাচিত টেক্সট বিকল্প নিয়ম নেই", + "Book Specific Rules": "বই নির্দিষ্ট নিয়ম", + "No book-level replacement rules": "কোনও বই-স্তরের বিকল্প নিয়ম নেই" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 64695375..4a2193d1 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -755,5 +755,24 @@ "Count": "ཨང་", "Start Page": "ཤོག་ངོས་དང་པོ", "Search in OPDS Catalog...": "OPDS དཀར་ཆག་ནང་འཚོལ།...", - "Please log in to use advanced TTS features": "དབང་བསྐྱོད་ཀྱི TTS རྣམ་པ་ཚུགས་སྤྱོད་བྱས་མ་ཐུབ།" + "Please log in to use advanced TTS features": "དབང་བསྐྱོད་ཀྱི TTS རྣམ་པ་ཚུགས་སྤྱོད་བྱས་མ་ཐུབ།", + "Word limit of 30 words exceeded.": "ཚིག་གཅིག 30 ལྟར་འདོད་མེད།", + "Proofread": "མིག་སྔའི་བཤད་རྒྱུན།", + "Current selection": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།", + "All occurrences in this book": "དེབ་ནང་གི་འདི་ཚུགས་ཀྱི་འཚོལ་ཞིབ།", + "All occurrences in your library": "ཁྱོད་ཀྱི་དེབ་མཛོད་ནང་གི་འདི་ཚུགས་ཀྱི་འཚོལ་ཞིབ།", + "Selected text:": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།", + "Replace with:": "བརྗེ་བ།:", + "Enter text...": "ཡིག་ཆ་ལ་འགྲོ།...", + "Case sensitive:": "གནས་སྟངས་འདི་ལ་འབད་དགོས།", + "Scope:": "གནས་ཚུལ།:", + "Selection": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།", + "Library": "དེབ་མཛོད།", + "Yes": "ཨིན་", + "No": "མེད་", + "Proofread Replacement Rules": "མིག་སྔའི་བཤད་རྒྱུན་བརྗེ་བའི་རྣམ་པ།", + "Selected Text Rules": "བདམས་པའི་ཡིག་ཆ་བརྗེ་སྒྱུར་སྲིད་སྒྲིག", + "No selected text replacement rules": "བདམས་པའི་ཡིག་ཆ་བརྗེ་སྒྱུར་སྲིད་སྒྲིག་མེད་པ", + "Book Specific Rules": "དེབ་སྒོས་ཀྱི་བརྗེ་སྒྱུར་སྲིད་སྒྲིག", + "No book-level replacement rules": "དེབ་རིམ་གྱི་བརྗེ་སྒྱུར་སྲིད་སྒྲིག་མེད་པ" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 0e052453..edf7af5f 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -765,5 +765,24 @@ "Count": "Anzahl", "Start Page": "Startseite", "Search in OPDS Catalog...": "Im OPDS-Katalog suchen...", - "Please log in to use advanced TTS features": "Bitte melde dich an, um erweiterte TTS-Funktionen zu nutzen" + "Please log in to use advanced TTS features": "Bitte melde dich an, um erweiterte TTS-Funktionen zu nutzen", + "Word limit of 30 words exceeded.": "Das Wortlimit von 30 Wörtern wurde überschritten.", + "Proofread": "Korrekturlesen", + "Current selection": "Aktuelle Auswahl", + "All occurrences in this book": "Alle Vorkommen in diesem Buch", + "All occurrences in your library": "Alle Vorkommen in Ihrer Bibliothek", + "Selected text:": "Ausgewählter Text:", + "Replace with:": "Ersetzen durch:", + "Enter text...": "Text eingeben …", + "Case sensitive:": "Groß-/Kleinschreibung:", + "Scope:": "Geltungsbereich:", + "Selection": "Auswahl", + "Library": "Bibliothek", + "Yes": "Ja", + "No": "Nein", + "Proofread Replacement Rules": "Korrektur-Ersetzungsregeln", + "Selected Text Rules": "Regeln für ausgewählten Text", + "No selected text replacement rules": "Keine Ersetzungsregeln für ausgewählten Text", + "Book Specific Rules": "Buchspezifische Regeln", + "No book-level replacement rules": "Keine Ersetzungsregeln auf Buchebene" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 93c4eeef..828fad6f 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -765,5 +765,24 @@ "Count": "Πλήθος", "Start Page": "Αρχική Σελίδα", "Search in OPDS Catalog...": "Αναζήτηση στον κατάλογο OPDS...", - "Please log in to use advanced TTS features": "Παρακαλώ συνδεθείτε για να χρησιμοποιήσετε προηγμένες λειτουργίες TTS" + "Please log in to use advanced TTS features": "Παρακαλώ συνδεθείτε για να χρησιμοποιήσετε προηγμένες λειτουργίες TTS", + "Word limit of 30 words exceeded.": "Υπέρβαση ορίου 30 λέξεων.", + "Proofread": "Διόρθωση", + "Current selection": "Τρέχουσα επιλογή", + "All occurrences in this book": "Όλες οι εμφανίσεις σε αυτό το βιβλίο", + "All occurrences in your library": "Όλες οι εμφανίσεις στη βιβλιοθήκη σας", + "Selected text:": "Επιλεγμένο κείμενο:", + "Replace with:": "Αντικατάσταση με:", + "Enter text...": "Εισαγωγή κειμένου…", + "Case sensitive:": "Διάκριση πεζών-κεφαλαίων:", + "Scope:": "Εύρος εφαρμογής:", + "Selection": "Επιλογή", + "Library": "Βιβλιοθήκη", + "Yes": "Ναι", + "No": "Όχι", + "Proofread Replacement Rules": "Κανόνες αντικατάστασης διόρθωσης", + "Selected Text Rules": "Κανόνες επιλεγμένου κειμένου", + "No selected text replacement rules": "Δεν υπάρχουν κανόνες αντικατάστασης για το επιλεγμένο κείμενο", + "Book Specific Rules": "Κανόνες για συγκεκριμένο βιβλίο", + "No book-level replacement rules": "Δεν υπάρχουν κανόνες αντικατάστασης σε επίπεδο βιβλίου" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 9aff2022..f3ab6fbb 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -775,5 +775,24 @@ "Count": "Cuenta", "Start Page": "Página de inicio", "Search in OPDS Catalog...": "Buscar en el catálogo OPDS...", - "Please log in to use advanced TTS features": "Por favor, inicie sesión para usar funciones avanzadas de TTS" + "Please log in to use advanced TTS features": "Por favor, inicie sesión para usar funciones avanzadas de TTS", + "Word limit of 30 words exceeded.": "Se superó el límite de 30 palabras.", + "Proofread": "Corrección", + "Current selection": "Selección actual", + "All occurrences in this book": "Todas las apariciones en este libro", + "All occurrences in your library": "Todas las apariciones en tu biblioteca", + "Selected text:": "Texto seleccionado:", + "Replace with:": "Reemplazar por:", + "Enter text...": "Introduce texto…", + "Case sensitive:": "Distinguir mayúsculas y minúsculas:", + "Scope:": "Ámbito:", + "Selection": "Selección", + "Library": "Biblioteca", + "Yes": "Sí", + "No": "No", + "Proofread Replacement Rules": "Reglas de reemplazo de corrección", + "Selected Text Rules": "Reglas de texto seleccionado", + "No selected text replacement rules": "No hay reglas de reemplazo para el texto seleccionado", + "Book Specific Rules": "Reglas específicas del libro", + "No book-level replacement rules": "No hay reglas de reemplazo a nivel de libro" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 0b7867fb..b01cbd3a 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -765,5 +765,24 @@ "Count": "تعداد", "Start Page": "صفحه شروع", "Search in OPDS Catalog...": "جستجو در فهرست OPDS...", - "Please log in to use advanced TTS features": "لطفاً برای استفاده از ویژگی‌های پیشرفته TTS وارد شوید." + "Please log in to use advanced TTS features": "لطفاً برای استفاده از ویژگی‌های پیشرفته TTS وارد شوید.", + "Word limit of 30 words exceeded.": "محدودیت ۳۰ واژه‌ای تجاوز شد.", + "Proofread": "بازبینی", + "Current selection": "انتخاب فعلی", + "All occurrences in this book": "همه موارد در این کتاب", + "All occurrences in your library": "همه موارد در کتابخانه شما", + "Selected text:": "متن انتخاب‌شده:", + "Replace with:": "جایگزین با:", + "Enter text...": "متن را وارد کنید…", + "Case sensitive:": "حساس به حروف بزرگ و کوچک:", + "Scope:": "دامنه:", + "Selection": "انتخاب", + "Library": "کتابخانه", + "Yes": "بله", + "No": "خیر", + "Proofread Replacement Rules": "قوانین جایگزینی بازبینی", + "Selected Text Rules": "قوانین متن انتخاب‌شده", + "No selected text replacement rules": "هیچ قانون جایگزینی برای متن انتخاب‌شده وجود ندارد", + "Book Specific Rules": "قوانین مخصوص کتاب", + "No book-level replacement rules": "هیچ قانون جایگزینی در سطح کتاب وجود ندارد" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 5761e7c2..4d1bcb33 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -775,5 +775,24 @@ "Count": "Nombre", "Start Page": "Page de départ", "Search in OPDS Catalog...": "Rechercher dans le catalogue OPDS...", - "Please log in to use advanced TTS features": "Veuillez vous connecter pour utiliser les fonctionnalités avancées de TTS" + "Please log in to use advanced TTS features": "Veuillez vous connecter pour utiliser les fonctionnalités avancées de TTS", + "Word limit of 30 words exceeded.": "La limite de 30 mots a été dépassée.", + "Proofread": "Correction", + "Current selection": "Sélection actuelle", + "All occurrences in this book": "Toutes les occurrences dans ce livre", + "All occurrences in your library": "Toutes les occurrences dans votre bibliothèque", + "Selected text:": "Texte sélectionné :", + "Replace with:": "Remplacer par :", + "Enter text...": "Saisir du texte…", + "Case sensitive:": "Respecter la casse :", + "Scope:": "Portée :", + "Selection": "Sélection", + "Library": "Bibliothèque", + "Yes": "Oui", + "No": "Non", + "Proofread Replacement Rules": "Règles de remplacement de correction", + "Selected Text Rules": "Règles du texte sélectionné", + "No selected text replacement rules": "Aucune règle de remplacement pour le texte sélectionné", + "Book Specific Rules": "Règles spécifiques au livre", + "No book-level replacement rules": "Aucune règle de remplacement au niveau du livre" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index b04f0ba7..2f56c464 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -765,5 +765,24 @@ "Count": "गणना", "Start Page": "प्रारंभ पृष्ठ", "Search in OPDS Catalog...": "OPDS कैटलॉग में खोजें...", - "Please log in to use advanced TTS features": "उन्नत TTS सुविधाओं का उपयोग करने के लिए कृपया लॉग इन करें।" + "Please log in to use advanced TTS features": "उन्नत TTS सुविधाओं का उपयोग करने के लिए कृपया लॉग इन करें।", + "Word limit of 30 words exceeded.": "30 शब्दों की सीमा पार हो गई है।", + "Proofread": "प्रूफ़रीड", + "Current selection": "वर्तमान चयन", + "All occurrences in this book": "इस पुस्तक में सभी स्थान", + "All occurrences in your library": "आपकी लाइब्रेरी में सभी स्थान", + "Selected text:": "चयनित पाठ:", + "Replace with:": "से बदलें:", + "Enter text...": "पाठ दर्ज करें…", + "Case sensitive:": "अक्षर संवेदनशील:", + "Scope:": "क्षेत्र:", + "Selection": "चयन", + "Library": "लाइब्रेरी", + "Yes": "हाँ", + "No": "नहीं", + "Proofread Replacement Rules": "प्रूफ़रीड प्रतिस्थापन नियम", + "Selected Text Rules": "चयनित पाठ के नियम", + "No selected text replacement rules": "चयनित पाठ के लिए कोई प्रतिस्थापन नियम नहीं हैं", + "Book Specific Rules": "पुस्तक-विशिष्ट नियम", + "No book-level replacement rules": "पुस्तक स्तर पर कोई प्रतिस्थापन नियम नहीं हैं" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index ded9ad7e..32945a56 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -755,5 +755,24 @@ "Count": "Jumlah", "Start Page": "Halaman Awal", "Search in OPDS Catalog...": "Cari di Katalog OPDS...", - "Please log in to use advanced TTS features": "Silakan masuk untuk menggunakan fitur TTS lanjutan" + "Please log in to use advanced TTS features": "Silakan masuk untuk menggunakan fitur TTS lanjutan", + "Word limit of 30 words exceeded.": "Batas 30 kata telah terlampaui.", + "Proofread": "Koreksi", + "Current selection": "Pilihan saat ini", + "All occurrences in this book": "Semua kemunculan di buku ini", + "All occurrences in your library": "Semua kemunculan di perpustakaan Anda", + "Selected text:": "Teks terpilih:", + "Replace with:": "Ganti dengan:", + "Enter text...": "Masukkan teks…", + "Case sensitive:": "Peka huruf besar dan kecil:", + "Scope:": "Cakupan:", + "Selection": "Pilihan", + "Library": "Perpustakaan", + "Yes": "Ya", + "No": "Tidak", + "Proofread Replacement Rules": "Aturan penggantian koreksi", + "Selected Text Rules": "Aturan teks terpilih", + "No selected text replacement rules": "Tidak ada aturan penggantian untuk teks terpilih", + "Book Specific Rules": "Aturan khusus buku", + "No book-level replacement rules": "Tidak ada aturan penggantian tingkat buku" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index e8316be4..3a2b1c6b 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -775,5 +775,24 @@ "Count": "Conteggio", "Start Page": "Pagina iniziale", "Search in OPDS Catalog...": "Cerca nel catalogo OPDS...", - "Please log in to use advanced TTS features": "Effettua il login per utilizzare le funzionalità TTS avanzate" + "Please log in to use advanced TTS features": "Effettua il login per utilizzare le funzionalità TTS avanzate", + "Word limit of 30 words exceeded.": "È stato superato il limite di 30 parole.", + "Proofread": "Revisione", + "Current selection": "Selezione corrente", + "All occurrences in this book": "Tutte le occorrenze in questo libro", + "All occurrences in your library": "Tutte le occorrenze nella tua libreria", + "Selected text:": "Testo selezionato:", + "Replace with:": "Sostituisci con:", + "Enter text...": "Inserisci testo…", + "Case sensitive:": "Maiuscole/minuscole:", + "Scope:": "Ambito:", + "Selection": "Selezione", + "Library": "Libreria", + "Yes": "Sì", + "No": "No", + "Proofread Replacement Rules": "Regole di sostituzione per la revisione", + "Selected Text Rules": "Regole per il testo selezionato", + "No selected text replacement rules": "Nessuna regola di sostituzione per il testo selezionato", + "Book Specific Rules": "Regole specifiche del libro", + "No book-level replacement rules": "Nessuna regola di sostituzione a livello di libro" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 26c75a85..3784f2c8 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -755,5 +755,24 @@ "Count": "件数", "Start Page": "開始ページ", "Search in OPDS Catalog...": "OPDSカタログ内を検索...", - "Please log in to use advanced TTS features": "高度なTTS機能を使用するにはログインしてください" + "Please log in to use advanced TTS features": "高度なTTS機能を使用するにはログインしてください", + "Word limit of 30 words exceeded.": "30語の上限を超えています。", + "Proofread": "校正", + "Current selection": "現在の選択", + "All occurrences in this book": "この本内のすべての出現箇所", + "All occurrences in your library": "ライブラリ内のすべての出現箇所", + "Selected text:": "選択されたテキスト:", + "Replace with:": "置換後:", + "Enter text...": "テキストを入力…", + "Case sensitive:": "大文字と小文字を区別:", + "Scope:": "適用範囲:", + "Selection": "選択", + "Library": "ライブラリ", + "Yes": "はい", + "No": "いいえ", + "Proofread Replacement Rules": "校正置換ルール", + "Selected Text Rules": "選択テキストのルール", + "No selected text replacement rules": "選択されたテキストの置換ルールはありません", + "Book Specific Rules": "書籍別ルール", + "No book-level replacement rules": "書籍レベルの置換ルールはありません" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index edfdb327..76d4fc00 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -755,5 +755,24 @@ "Count": "개수", "Start Page": "시작 페이지", "Search in OPDS Catalog...": "OPDS 카탈로그에서 검색...", - "Please log in to use advanced TTS features": "고급 TTS 기능을 사용하려면 로그인하세요" + "Please log in to use advanced TTS features": "고급 TTS 기능을 사용하려면 로그인하세요", + "Word limit of 30 words exceeded.": "30단어 제한을 초과했습니다.", + "Proofread": "교정", + "Current selection": "현재 선택", + "All occurrences in this book": "이 책의 모든 위치", + "All occurrences in your library": "라이브러리의 모든 위치", + "Selected text:": "선택한 텍스트:", + "Replace with:": "다음으로 바꾸기:", + "Enter text...": "텍스트 입력…", + "Case sensitive:": "대소문자 구분:", + "Scope:": "적용 범위:", + "Selection": "선택", + "Library": "라이브러리", + "Yes": "예", + "No": "아니요", + "Proofread Replacement Rules": "교정 대체 규칙", + "Selected Text Rules": "선택한 텍스트 규칙", + "No selected text replacement rules": "선택한 텍스트에 대한 대체 규칙이 없습니다", + "Book Specific Rules": "책별 규칙", + "No book-level replacement rules": "책 수준의 대체 규칙이 없습니다" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 368ca079..c774f977 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -755,5 +755,24 @@ "Count": "Jumlah", "Start Page": "Halaman Awal", "Search in OPDS Catalog...": "Cari dalam Katalog OPDS...", - "Please log in to use advanced TTS features": "Sila log masuk untuk menggunakan ciri TTS lanjutan" + "Please log in to use advanced TTS features": "Sila log masuk untuk menggunakan ciri TTS lanjutan", + "Word limit of 30 words exceeded.": "Had 30 perkataan telah dilebihi.", + "Proofread": "Semakan", + "Current selection": "Pilihan semasa", + "All occurrences in this book": "Semua kejadian dalam buku ini", + "All occurrences in your library": "Semua kejadian dalam perpustakaan anda", + "Selected text:": "Teks dipilih:", + "Replace with:": "Ganti dengan:", + "Enter text...": "Masukkan teks…", + "Case sensitive:": "Peka huruf besar dan kecil:", + "Scope:": "Skop:", + "Selection": "Pilihan", + "Library": "Perpustakaan", + "Yes": "Ya", + "No": "Tidak", + "Proofread Replacement Rules": "Peraturan penggantian semakan", + "Selected Text Rules": "Peraturan teks dipilih", + "No selected text replacement rules": "Tiada peraturan penggantian untuk teks dipilih", + "Book Specific Rules": "Peraturan khusus buku", + "No book-level replacement rules": "Tiada peraturan penggantian peringkat buku" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index d1cb86a3..ffed771c 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -765,5 +765,24 @@ "Count": "Aantal", "Start Page": "Startpagina", "Search in OPDS Catalog...": "Zoeken in OPDS-catalogus...", - "Please log in to use advanced TTS features": "Log in om geavanceerde TTS-functies te gebruiken" + "Please log in to use advanced TTS features": "Log in om geavanceerde TTS-functies te gebruiken", + "Word limit of 30 words exceeded.": "De limiet van 30 woorden is overschreden.", + "Proofread": "Correctie", + "Current selection": "Huidige selectie", + "All occurrences in this book": "Alle voorkomens in dit boek", + "All occurrences in your library": "Alle voorkomens in je bibliotheek", + "Selected text:": "Geselecteerde tekst:", + "Replace with:": "Vervangen door:", + "Enter text...": "Tekst invoeren…", + "Case sensitive:": "Hoofdlettergevoelig:", + "Scope:": "Bereik:", + "Selection": "Selectie", + "Library": "Bibliotheek", + "Yes": "Ja", + "No": "Nee", + "Proofread Replacement Rules": "Correctie-vervangingsregels", + "Selected Text Rules": "Regels voor geselecteerde tekst", + "No selected text replacement rules": "Geen vervangingsregels voor geselecteerde tekst", + "Book Specific Rules": "Boekspecifieke regels", + "No book-level replacement rules": "Geen vervangingsregels op boekniveau" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 2c8385f4..07d6d9d1 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -785,5 +785,24 @@ "Count": "Liczba", "Start Page": "Strona startowa", "Search in OPDS Catalog...": "Szukaj w katalogu OPDS...", - "Please log in to use advanced TTS features": "Zaloguj się, aby korzystać z zaawansowanych funkcji TTS" + "Please log in to use advanced TTS features": "Zaloguj się, aby korzystać z zaawansowanych funkcji TTS", + "Word limit of 30 words exceeded.": "Przekroczono limit 30 słów.", + "Proofread": "Korekta", + "Current selection": "Bieżące zaznaczenie", + "All occurrences in this book": "Wszystkie wystąpienia w tej książce", + "All occurrences in your library": "Wszystkie wystąpienia w Twojej bibliotece", + "Selected text:": "Zaznaczony tekst:", + "Replace with:": "Zastąp przez:", + "Enter text...": "Wpisz tekst…", + "Case sensitive:": "Uwzględniaj wielkość liter:", + "Scope:": "Zakres:", + "Selection": "Zaznaczenie", + "Library": "Biblioteka", + "Yes": "Tak", + "No": "Nie", + "Proofread Replacement Rules": "Reguły zastępowania korekty", + "Selected Text Rules": "Reguły dla zaznaczonego tekstu", + "No selected text replacement rules": "Brak reguł zastępowania dla zaznaczonego tekstu", + "Book Specific Rules": "Reguły specyficzne dla książki", + "No book-level replacement rules": "Brak reguł zastępowania na poziomie książki" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index cae9c92c..292d004a 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -775,5 +775,24 @@ "Count": "Contagem", "Start Page": "Página Inicial", "Search in OPDS Catalog...": "Pesquisar no Catálogo OPDS...", - "Please log in to use advanced TTS features": "Por favor, faça login para usar recursos avançados de TTS" + "Please log in to use advanced TTS features": "Por favor, faça login para usar recursos avançados de TTS", + "Word limit of 30 words exceeded.": "O limite de 30 palavras foi excedido.", + "Proofread": "Revisão", + "Current selection": "Seleção atual", + "All occurrences in this book": "Todas as ocorrências neste livro", + "All occurrences in your library": "Todas as ocorrências na sua biblioteca", + "Selected text:": "Texto selecionado:", + "Replace with:": "Substituir por:", + "Enter text...": "Introduzir texto…", + "Case sensitive:": "Diferenciar maiúsculas e minúsculas:", + "Scope:": "Âmbito:", + "Selection": "Seleção", + "Library": "Biblioteca", + "Yes": "Sim", + "No": "Não", + "Proofread Replacement Rules": "Regras de substituição da revisão", + "Selected Text Rules": "Regras do texto selecionado", + "No selected text replacement rules": "Não existem regras de substituição para o texto selecionado", + "Book Specific Rules": "Regras específicas do livro", + "No book-level replacement rules": "Não existem regras de substituição ao nível do livro" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index e18b0041..a2ff902a 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -785,5 +785,24 @@ "Count": "Количество", "Start Page": "Начальная страница", "Search in OPDS Catalog...": "Поиск в каталоге OPDS...", - "Please log in to use advanced TTS features": "Пожалуйста, войдите в систему, чтобы использовать расширенные функции TTS" + "Please log in to use advanced TTS features": "Пожалуйста, войдите в систему, чтобы использовать расширенные функции TTS", + "Word limit of 30 words exceeded.": "Превышен лимит в 30 слов.", + "Proofread": "Корректура", + "Current selection": "Текущее выделение", + "All occurrences in this book": "Все вхождения в этой книге", + "All occurrences in your library": "Все вхождения в вашей библиотеке", + "Selected text:": "Выделенный текст:", + "Replace with:": "Заменить на:", + "Enter text...": "Введите текст…", + "Case sensitive:": "Учитывать регистр:", + "Scope:": "Область применения:", + "Selection": "Выделение", + "Library": "Библиотека", + "Yes": "Да", + "No": "Нет", + "Proofread Replacement Rules": "Правила замены для корректуры", + "Selected Text Rules": "Правила для выделенного текста", + "No selected text replacement rules": "Нет правил замены для выделенного текста", + "Book Specific Rules": "Правила для конкретной книги", + "No book-level replacement rules": "Нет правил замены на уровне книги" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 357c8364..90d4e497 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -765,5 +765,24 @@ "Count": "ගණන", "Start Page": "ආරම්භක පිටුව", "Search in OPDS Catalog...": "OPDS දත්තසමුදා තුළ සෙවීම...", - "Please log in to use advanced TTS features": "උසස් TTS විශේෂාංග භාවිතා කිරීමට කරුණාකර පිවිසෙන්න" + "Please log in to use advanced TTS features": "උසස් TTS විශේෂාංග භාවිතා කිරීමට කරුණාකර පිවිසෙන්න", + "Word limit of 30 words exceeded.": "වචන 30ක සීමාව ඉක්මවා ඇත.", + "Proofread": "සංශෝධනය", + "Current selection": "වත්මන් තේරීම", + "All occurrences in this book": "මෙම පොත තුළ ඇති සියලුම පෙනීම්", + "All occurrences in your library": "ඔබගේ පුස්තකාලයේ ඇති සියලුම පෙනීම්", + "Selected text:": "තේරූ පෙළ:", + "Replace with:": "මෙයින් ප්‍රතිස්ථාපනය කරන්න:", + "Enter text...": "පෙළ ඇතුළත් කරන්න…", + "Case sensitive:": "අකුරු විශාල/කුඩා භේදය සලකා බලන්න:", + "Scope:": "පරාසය:", + "Selection": "තේරීම", + "Library": "පුස්තකාලය", + "Yes": "ඔව්", + "No": "නැහැ", + "Proofread Replacement Rules": "සංශෝධන ප්‍රතිස්ථාපන නියමයන්", + "Selected Text Rules": "තේරූ පෙළ සඳහා නියමයන්", + "No selected text replacement rules": "තේරූ පෙළ සඳහා ප්‍රතිස්ථාපන නියමයන් නොමැත", + "Book Specific Rules": "පොතට විශේෂිත නියමයන්", + "No book-level replacement rules": "පොත් මට්ටමේ ප්‍රතිස්ථාපන නියමයන් නොමැත" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index a298c197..00016a8e 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -765,5 +765,24 @@ "Count": "Antal", "Start Page": "Start sida", "Search in OPDS Catalog...": "Sök i OPDS-katalog...", - "Please log in to use advanced TTS features": "Logga in för att använda avancerade TTS-funktioner" + "Please log in to use advanced TTS features": "Logga in för att använda avancerade TTS-funktioner", + "Word limit of 30 words exceeded.": "Gränsen på 30 ord har överskridits.", + "Proofread": "Korrekturläsning", + "Current selection": "Aktuell markering", + "All occurrences in this book": "Alla förekomster i den här boken", + "All occurrences in your library": "Alla förekomster i ditt bibliotek", + "Selected text:": "Markerad text:", + "Replace with:": "Ersätt med:", + "Enter text...": "Ange text…", + "Case sensitive:": "Skiftlägeskänslig:", + "Scope:": "Omfattning:", + "Selection": "Markering", + "Library": "Bibliotek", + "Yes": "Ja", + "No": "Nej", + "Proofread Replacement Rules": "Ersättningsregler för korrekturläsning", + "Selected Text Rules": "Regler för markerad text", + "No selected text replacement rules": "Inga ersättningsregler för markerad text", + "Book Specific Rules": "Bokspecifika regler", + "No book-level replacement rules": "Inga ersättningsregler på boknivå" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index b5ff9b34..f362bccd 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -765,5 +765,24 @@ "Count": "எண்ணிக்கை", "Start Page": "தொடக்கப் பக்கம்", "Search in OPDS Catalog...": "OPDS பட்டியலில் தேடவும்...", - "Please log in to use advanced TTS features": "மேம்பட்ட TTS அம்சங்களை பயன்படுத்த உள்நுழையவும்" + "Please log in to use advanced TTS features": "மேம்பட்ட TTS அம்சங்களை பயன்படுத்த உள்நுழையவும்", + "Word limit of 30 words exceeded.": "30 சொற்களின் வரம்பை மீறியுள்ளது.", + "Proofread": "திருத்தம்", + "Current selection": "தற்போதைய தேர்வு", + "All occurrences in this book": "இந்த புத்தகத்தில் உள்ள அனைத்து நிகழ்வுகளும்", + "All occurrences in your library": "உங்கள் நூலகத்தில் உள்ள அனைத்து நிகழ்வுகளும்", + "Selected text:": "தேர்ந்தெடுக்கப்பட்ட உரை:", + "Replace with:": "இதனால் மாற்றவும்:", + "Enter text...": "உரையை உள்ளிடவும்…", + "Case sensitive:": "எழுத்து பெரிய/சிறிய வேறுபாட்டை கருத்தில் கொள்ளவும்:", + "Scope:": "வளயம்:", + "Selection": "தேர்வு", + "Library": "நூலகம்", + "Yes": "ஆம்", + "No": "இல்லை", + "Proofread Replacement Rules": "திருத்த மாற்று விதிகள்", + "Selected Text Rules": "தேர்ந்தெடுக்கப்பட்ட உரைக்கான விதிகள்", + "No selected text replacement rules": "தேர்ந்தெடுக்கப்பட்ட உரைக்கான மாற்று விதிகள் இல்லை", + "Book Specific Rules": "புத்தகத்திற்கு குறிப்பிட்ட விதிகள்", + "No book-level replacement rules": "புத்தக மட்டத்தில் மாற்று விதிகள் இல்லை" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 3856579a..73051390 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -755,5 +755,24 @@ "Count": "นับ", "Start Page": "หน้าเริ่มต้น", "Search in OPDS Catalog...": "ค้นหาในแคตตาล็อก OPDS...", - "Please log in to use advanced TTS features": "กรุณาเข้าสู่ระบบเพื่อใช้ฟีเจอร์ TTS ขั้นสูง" + "Please log in to use advanced TTS features": "กรุณาเข้าสู่ระบบเพื่อใช้ฟีเจอร์ TTS ขั้นสูง", + "Word limit of 30 words exceeded.": "เกินขีดจำกัด 30 คำแล้ว", + "Proofread": "ตรวจทาน", + "Current selection": "การเลือกปัจจุบัน", + "All occurrences in this book": "การปรากฏทั้งหมดในหนังสือเล่มนี้", + "All occurrences in your library": "การปรากฏทั้งหมดในคลังหนังสือของคุณ", + "Selected text:": "ข้อความที่เลือก:", + "Replace with:": "แทนที่ด้วย:", + "Enter text...": "ป้อนข้อความ…", + "Case sensitive:": "คำนึงถึงตัวพิมพ์เล็ก/ใหญ่:", + "Scope:": "ขอบเขต:", + "Selection": "การเลือก", + "Library": "คลังหนังสือ", + "Yes": "ใช่", + "No": "ไม่", + "Proofread Replacement Rules": "กฎการแทนที่สำหรับการตรวจทาน", + "Selected Text Rules": "กฎสำหรับข้อความที่เลือก", + "No selected text replacement rules": "ไม่มีกฎการแทนที่สำหรับข้อความที่เลือก", + "Book Specific Rules": "กฎเฉพาะของหนังสือ", + "No book-level replacement rules": "ไม่มีกฎการแทนที่ระดับหนังสือ" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 46cf8461..22dfaa97 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -765,5 +765,24 @@ "Count": "Sayım", "Start Page": "Başlangıç Sayfası", "Search in OPDS Catalog...": "OPDS Kataloğunda ara...", - "Please log in to use advanced TTS features": "Gelişmiş TTS özelliklerini kullanmak için lütfen giriş yapın" + "Please log in to use advanced TTS features": "Gelişmiş TTS özelliklerini kullanmak için lütfen giriş yapın", + "Word limit of 30 words exceeded.": "30 kelime sınırı aşıldı.", + "Proofread": "Düzelt", + "Current selection": "Mevcut seçim", + "All occurrences in this book": "Bu kitaptaki tüm geçenler", + "All occurrences in your library": "Kütüphanenizdeki tüm geçenler", + "Selected text:": "Seçili metin:", + "Replace with:": "Şununla değiştir:", + "Enter text...": "Metin girin…", + "Case sensitive:": "Büyük/küçük harfe duyarlı:", + "Scope:": "Kapsam:", + "Selection": "Seçim", + "Library": "Kütüphane", + "Yes": "Evet", + "No": "Hayır", + "Proofread Replacement Rules": "Düzeltme Değiştirme Kuralları", + "Selected Text Rules": "Seçili Metin Kuralları", + "No selected text replacement rules": "Seçili metin için değiştirme kuralı yok", + "Book Specific Rules": "Kitaba Özel Kurallar", + "No book-level replacement rules": "Kitap düzeyinde değiştirme kuralı yok" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 09bb9754..8d911e37 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -785,5 +785,24 @@ "Count": "Кількість", "Start Page": "Початкова сторінка", "Search in OPDS Catalog...": "Пошук у каталозі OPDS...", - "Please log in to use advanced TTS features": "Будь ласка, увійдіть, щоб використовувати розширені функції TTS" + "Please log in to use advanced TTS features": "Будь ласка, увійдіть, щоб використовувати розширені функції TTS", + "Word limit of 30 words exceeded.": "Перевищено ліміт у 30 слів.", + "Proofread": "Вичитування", + "Current selection": "Поточне виділення", + "All occurrences in this book": "Усі входження в цій книзі", + "All occurrences in your library": "Усі входження у вашій бібліотеці", + "Selected text:": "Виділений текст:", + "Replace with:": "Замінити на:", + "Enter text...": "Введіть текст…", + "Case sensitive:": "З урахуванням регістру:", + "Scope:": "Область застосування:", + "Selection": "Виділення", + "Library": "Бібліотека", + "Yes": "Так", + "No": "Ні", + "Proofread Replacement Rules": "Правила заміни для вичитування", + "Selected Text Rules": "Правила для виділеного тексту", + "No selected text replacement rules": "Немає правил заміни для виділеного тексту", + "Book Specific Rules": "Правила для конкретної книги", + "No book-level replacement rules": "Немає правил заміни на рівні книги" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index fe0ea400..388b3dbf 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -755,5 +755,24 @@ "Count": "Số lượng", "Start Page": "Trang bắt đầu", "Search in OPDS Catalog...": "Tìm kiếm trong danh mục OPDS...", - "Please log in to use advanced TTS features": "Vui lòng đăng nhập để sử dụng các tính năng TTS nâng cao" + "Please log in to use advanced TTS features": "Vui lòng đăng nhập để sử dụng các tính năng TTS nâng cao", + "Word limit of 30 words exceeded.": "Đã vượt quá giới hạn 30 từ.", + "Proofread": "Hiệu đính", + "Current selection": "Vùng chọn hiện tại", + "All occurrences in this book": "Tất cả các lần xuất hiện trong sách này", + "All occurrences in your library": "Tất cả các lần xuất hiện trong thư viện của bạn", + "Selected text:": "Văn bản đã chọn:", + "Replace with:": "Thay thế bằng:", + "Enter text...": "Nhập văn bản…", + "Case sensitive:": "Phân biệt chữ hoa/thường:", + "Scope:": "Phạm vi:", + "Selection": "Vùng chọn", + "Library": "Thư viện", + "Yes": "Có", + "No": "Không", + "Proofread Replacement Rules": "Quy tắc thay thế hiệu đính", + "Selected Text Rules": "Quy tắc cho văn bản đã chọn", + "No selected text replacement rules": "Không có quy tắc thay thế cho văn bản đã chọn", + "Book Specific Rules": "Quy tắc dành riêng cho sách", + "No book-level replacement rules": "Không có quy tắc thay thế ở cấp sách" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 504311fd..fc24355f 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -755,5 +755,24 @@ "Count": "数量", "Start Page": "起始页", "Search in OPDS Catalog...": "在 OPDS 目录中搜索...", - "Please log in to use advanced TTS features": "请登录以使用高级 TTS 功能" + "Please log in to use advanced TTS features": "请登录以使用高级 TTS 功能", + "Word limit of 30 words exceeded.": "已超过 30 个词的限制。", + "Proofread": "校对", + "Current selection": "当前选中内容", + "All occurrences in this book": "本书中的所有出现位置", + "All occurrences in your library": "整个书库中的所有出现位置", + "Selected text:": "选中文本:", + "Replace with:": "替换为:", + "Enter text...": "请输入文本…", + "Case sensitive:": "区分大小写:", + "Scope:": "作用范围:", + "Selection": "选中内容", + "Library": "书库", + "Yes": "是", + "No": "否", + "Proofread Replacement Rules": "校对替换规则", + "Selected Text Rules": "选中文本规则", + "No selected text replacement rules": "暂无选中文本的替换规则", + "Book Specific Rules": "书籍范围规则", + "No book-level replacement rules": "暂无书籍级别的替换规则" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index f1b07ea5..69a91d97 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -755,5 +755,24 @@ "Count": "數量", "Start Page": "起始頁", "Search in OPDS Catalog...": "在 OPDS 目錄中搜尋...", - "Please log in to use advanced TTS features": "請登入以使用進階 TTS 功能" + "Please log in to use advanced TTS features": "請登入以使用進階 TTS 功能", + "Word limit of 30 words exceeded.": "已超過 30 個詞的限制。", + "Proofread": "校對", + "Current selection": "目前選取內容", + "All occurrences in this book": "本書中的所有出現位置", + "All occurrences in your library": "整個書庫中的所有出現位置", + "Selected text:": "已選取文字:", + "Replace with:": "取代為:", + "Enter text...": "請輸入文字…", + "Case sensitive:": "區分大小寫:", + "Scope:": "適用範圍:", + "Selection": "選取內容", + "Library": "書庫", + "Yes": "是", + "No": "否", + "Proofread Replacement Rules": "校對取代規則", + "Selected Text Rules": "已選取文字規則", + "No selected text replacement rules": "沒有已選取文字的取代規則", + "Book Specific Rules": "書籍層級規則", + "No book-level replacement rules": "沒有書籍層級的取代規則" } diff --git a/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx b/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx new file mode 100644 index 00000000..71e6dd09 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { EnvProvider } from '@/context/EnvContext'; +import ProofreadPopup from '@/app/reader/components/annotator/ProofreadPopup'; + +vi.mock('@/services/environment', async () => { + const actual = await vi.importActual('@/services/environment'); + + const mockAppService = { + init: vi.fn().mockResolvedValue(undefined), + // Add any other methods from AppService interface + }; + + return { + ...actual, + default: { + getAppService: vi.fn().mockResolvedValue(mockAppService), + }, + }; +}); + +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +function renderWithProviders(ui: React.ReactNode) { + return render({ui}); +} + +describe('ProofreadPopup Component', () => { + const mockOnConfirm = vi.fn(); + const mockOnClose = vi.fn(); + + const defaultProps = { + bookKey: 'test-book', + isVertical: false, + selectedText: 'test word', + selection: { + key: 'test-book', + text: 'test word', + cfi: 'epubcfi(/6/2[chapter1]!/4/1:0)', + range: { + deleteContents: vi.fn(), + insertNode: vi.fn(), + startContainer: document.createTextNode('test word here'), + endContainer: document.createTextNode('test word here'), + startOffset: 5, + endOffset: 9, + } as unknown as Range, + index: 0, + }, + position: { point: { x: 100, y: 100 } }, + trianglePosition: { point: { x: 100, y: 100 } }, + popupWidth: 440, + popupHeight: 200, + onConfirm: mockOnConfirm, + onDismiss: mockOnClose, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Rendering', () => { + it('should render default replacement scope options', () => { + renderWithProviders(); + + expect(screen.getByText('Current selection')).toBeTruthy(); + }); + + it('should render the replacement text input field', () => { + renderWithProviders(); + + const input = screen.getByPlaceholderText('Enter text...'); + expect(input).toBeTruthy(); + }); + + it('should render the case sensitive checkbox', () => { + const { container } = renderWithProviders(); + + expect(screen.getByText('Case sensitive:')).toBeTruthy(); + expect(container.querySelector('input[type="checkbox"]')).toBeTruthy(); + }); + + it('should render the Apply button', () => { + renderWithProviders(); + + expect(screen.getByText('Apply')).toBeTruthy(); + }); + + it('should display selected text preview', () => { + renderWithProviders(); + + expect(screen.getByText(/Selected text:/)).toBeTruthy(); + expect(screen.getByText(/"test word"/)).toBeTruthy(); + }); + }); + + describe('Case Sensitive Checkbox', () => { + it('should be checked by default (case-sensitive)', () => { + const { container } = renderWithProviders(); + + const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it('should toggle when clicked', async () => { + const { container } = renderWithProviders(); + + const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + + fireEvent.click(checkbox); + expect(checkbox.checked).toBe(false); + + fireEvent.click(checkbox); + expect(checkbox.checked).toBe(true); + }); + }); + + describe('Replacement Text Input', () => { + it('should update value when user types', () => { + renderWithProviders(); + + const input = screen.getByPlaceholderText('Enter text...') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'new text' } }); + + expect(input.value).toBe('new text'); + }); + + it('should trim whitespace from replacement text', () => { + renderWithProviders(); + + const input = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(input, { target: { value: ' trimmed ' } }); + + const confirmButton = screen.getByText('Apply'); + fireEvent.click(confirmButton); + + expect(mockOnConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + replacement: 'trimmed', + }), + ); + }); + }); + + describe('Scope Selection Handlers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createValidSelection = () => ({ + ...defaultProps, + selection: { + ...defaultProps.selection, + text: 'word', + cfi: 'epubcfi(/6/4[chap01ref]!/4/2/1:0)', + range: { + deleteContents: vi.fn(), + insertNode: vi.fn(), + startContainer: document.createTextNode('test word here'), + endContainer: document.createTextNode('test word here'), + startOffset: 5, + endOffset: 9, + } as unknown as Range, + }, + }); + + it('should call onConfirm with correct scope for "selection"', async () => { + renderWithProviders(); + + const input = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(input, { target: { value: 'replacement' } }); + + const applyButton = screen.getByText('Apply'); + fireEvent.click(applyButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'selection' })); + }); + }); + + it('should call onConfirm with correct scope for "book"', async () => { + renderWithProviders(); + + const input = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(input, { target: { value: 'replacement' } }); + + const scopeSelect = screen.getByRole('combobox'); + fireEvent.change(scopeSelect, { target: { value: 'book' } }); + + const applyButton = screen.getByText('Apply'); + fireEvent.click(applyButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'book' })); + }); + }); + + it('should call onConfirm with correct scope for "library"', async () => { + renderWithProviders(); + + const input = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(input, { target: { value: 'replacement' } }); + + const scopeSelect = screen.getByRole('combobox'); + fireEvent.change(scopeSelect, { target: { value: 'library' } }); + + const applyButton = screen.getByText('Apply'); + fireEvent.click(applyButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'library' })); + }); + }); + }); + + describe('Click Outside Behavior', () => { + it('should not call onClose when clicking inside the menu', () => { + renderWithProviders(); + + const input = screen.getByPlaceholderText('Enter text...'); + fireEvent.mouseDown(input); + + expect(mockOnClose).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx b/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx new file mode 100644 index 00000000..235d1ae6 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx @@ -0,0 +1,559 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, within } from '@testing-library/react'; +import React from 'react'; +import { vi } from 'vitest'; + +import { + ProofreadRulesManager, + setProofreadRulesVisibility, +} from '@/app/reader/components/ProofreadRules'; +import BookMenu from '@/app/reader/components/sidebar/BookMenu'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useReaderStore } from '@/store/readerStore'; +import { useSidebarStore } from '@/store/sidebarStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { ProofreadRule } from '@/types/book'; + +// ------------------------------ +// NEXT.JS ROUTER MOCK +// ------------------------------ +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + }), + useSearchParams: () => ({ + get: () => null, + toString: () => '', + }), +})); + +// ------------------------------ +// TRANSLATION MOCK +// ------------------------------ +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); +vi.mock('@/services/translators/cache', () => ({ + initCache: vi.fn(), + loadCacheFromDB: vi.fn(), + pruneCache: vi.fn(), +})); + +// ------------------------------ +// ENV PROVIDER WRAPPER +// ------------------------------ +// mock environment module so EnvProvider uses fake values +vi.mock('@/services/environment', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...(typeof actual === 'object' && actual !== null ? actual : {}), // keep all real exports (e.g., isTauriAppPlatform) + + default: { + ...(typeof actual === 'object' && + actual !== null && + 'default' in actual && + typeof actual.default === 'object' && + actual.default !== null + ? actual.default + : {}), // keep all real default fields + API_BASE: 'http://localhost', + ENABLE_TRANSLATOR: false, + getAppService: vi.fn().mockResolvedValue(null), + }, + }; +}); + +import { EnvProvider } from '@/context/EnvContext'; + +function renderWithProviders(ui: React.ReactNode) { + return render({ui}); +} + +describe('ProofreadRulesManager', () => { + beforeEach(() => { + // Reset stores + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { + globalViewSettings: { proofreadRules: [] }, + kosync: { + enabled: false, + }, + }, + }); + (useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} }); + useSidebarStore.setState({ sideBarBookKey: null }); + (useBookDataStore.setState as unknown as (state: unknown) => void)({ booksData: {} }); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders book and library (global) proofreading rules from stores', async () => { + // Arrange: populate stores + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { + globalViewSettings: { + proofreadRules: [ + { + id: 'g1', + scope: 'library', + pattern: 'foo', + replacement: 'bar', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + }, + { + id: 'g2', + scope: 'library', + pattern: 'hello', + replacement: 'world', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 2, + wholeWord: true, + }, + ], + }, + kosync: { enabled: false }, + }, + }); + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { + book1: { + viewSettings: { + proofreadRules: [], + }, + }, + }, + }); + + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + // Act: render and open dialog + renderWithProviders(); + // wait a tick so the component's effect attaches the event listener + await Promise.resolve(); + // open via helper which dispatches the custom event + setProofreadRulesVisibility(true); + + // Assert + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeTruthy(); + // Library (global) rules + expect(screen.getByText('foo')).toBeTruthy(); + expect(screen.getByText('bar')).toBeTruthy(); + expect(screen.getByText('hello')).toBeTruthy(); + expect(screen.getByText('world')).toBeTruthy(); + }); + + it('renders selection rules separately from book/library rules', async () => { + // Arrange: populate stores with a selection rule persisted in book config + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { + globalViewSettings: { proofreadRules: [] }, + kosync: { enabled: false }, + }, + }); + + const selectionRule: ProofreadRule = { + id: 's1', + scope: 'selection', + pattern: 'only-once', + replacement: 'single-hit', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)', + sectionHref: 'chapter1.html', + }; + + const bookRule: ProofreadRule = { + id: 'b1', + scope: 'book', + pattern: 'book-wide', + replacement: 'book-hit', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 2, + wholeWord: true, + }; + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { + book1: { + viewSettings: { + proofreadRules: [selectionRule, bookRule], + }, + }, + }, + }); + + (useBookDataStore.setState as unknown as (state: unknown) => void)({ + booksData: { + book1: { + id: 'book1', + book: null, + file: null, + config: { + viewSettings: { + proofreadRules: [selectionRule, bookRule], + }, + }, + bookDoc: null, + isFixedLayout: false, + }, + }, + }); + + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + // Act: render and open dialog + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + + // Assert + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeTruthy(); + + // Single Instance Rules section + expect(screen.getByText('Selected Text Rules')).toBeTruthy(); + expect(screen.getByText('only-once')).toBeTruthy(); + expect(screen.getByText('single-hit')).toBeTruthy(); + + // Book section should still show book-wide rule + expect(screen.getByText('book-wide')).toBeTruthy(); + expect(screen.getByText('book-hit')).toBeTruthy(); + }); + + it('displays correct scope labels for different rule types', async () => { + const selectionRule: ProofreadRule = { + id: 's1', + scope: 'selection', + pattern: 'select-text', + replacement: 'replaced', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)', + sectionHref: 'chapter1.html', + }; + + const bookRule: ProofreadRule = { + id: 'b1', + scope: 'book', + pattern: 'book-pattern', + replacement: 'book-replaced', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 2, + wholeWord: true, + }; + + const libraryRule: ProofreadRule = { + id: 'l1', + scope: 'library', + pattern: 'library-pattern', + replacement: 'library-replaced', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 3, + wholeWord: true, + }; + + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { + globalViewSettings: { + proofreadRules: [libraryRule], + }, + kosync: { enabled: false }, + }, + }); + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { + book1: { + viewSettings: { + proofreadRules: [selectionRule, bookRule], + }, + }, + }, + }); + + (useBookDataStore.setState as unknown as (state: unknown) => void)({ + booksData: { + book1: { + id: 'book1', + book: null, + file: null, + config: { + viewSettings: { + proofreadRules: [selectionRule, bookRule], + }, + }, + bookDoc: null, + isFixedLayout: false, + }, + }, + }); + + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeTruthy(); + + const selectionRuleElement = screen.getByText('select-text').closest('li'); + expect(within(selectionRuleElement!).getByText(/Selection/)).toBeTruthy(); + + const bookRuleElement = screen.getByText('book-pattern').closest('li'); + expect(within(bookRuleElement!).getByText(/Book/)).toBeTruthy(); + + const libraryRuleElement = screen.getByText('library-pattern').closest('li'); + expect(within(libraryRuleElement!).getByText(/Library/)).toBeTruthy(); + }); + + it('shows case sensitivity status for each rule', async () => { + const caseSensitiveRule: ProofreadRule = { + id: 'cs1', + scope: 'book', + pattern: 'case-sensitive', + replacement: 'CS-REPLACED', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + }; + + const caseInsensitiveRule: ProofreadRule = { + id: 'ci1', + scope: 'book', + pattern: 'case-insensitive', + replacement: 'CI-REPLACED', + enabled: true, + isRegex: false, + caseSensitive: false, + order: 2, + wholeWord: true, + }; + + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { + globalViewSettings: { proofreadRules: [] }, + kosync: { enabled: false }, + }, + }); + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { + book1: { + viewSettings: { + proofreadRules: [caseSensitiveRule, caseInsensitiveRule], + }, + }, + }, + }); + + (useBookDataStore.setState as unknown as (state: unknown) => void)({ + booksData: { + book1: { + id: 'book1', + book: null, + file: null, + config: { + viewSettings: { + proofreadRules: [caseSensitiveRule, caseInsensitiveRule], + }, + }, + bookDoc: null, + isFixedLayout: false, + }, + }, + }); + + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeTruthy(); + + const csRuleElement = screen.getByText('case-sensitive').closest('li'); + expect(within(csRuleElement!).getByText(/Case sensitive:/)).toBeTruthy(); + expect(within(csRuleElement!).getByText(/Yes/)).toBeTruthy(); + + const ciRuleElement = screen.getByText('case-insensitive').closest('li'); + expect(within(ciRuleElement!).getByText(/Case sensitive:/)).toBeTruthy(); + expect(within(ciRuleElement!).getByText(/No/)).toBeTruthy(); + }); + + it('opens when BookMenu item is clicked (integration)', async () => { + // Arrange stores + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { + globalViewSettings: { proofreadRules: [] }, + kosync: { enabled: false }, + }, + }); + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { + book1: { viewSettings: { proofreadRules: [] } }, + }, + }); + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + // Render both menu and window + renderWithProviders( +
+ + +
, + ); + + // wait a tick so effects attach + await Promise.resolve(); + + // Click the menu item + const menuItem = screen.getByRole('menuitem', { name: 'Proofread' }); + fireEvent.click(menuItem); + + // The dialog should open + const dialog = await screen.findByRole('dialog'); + + expect(within(dialog).getByText('Proofread Replacement Rules')).toBeTruthy(); + }); + + it('shows empty state messages when no rules exist', async () => { + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { + globalViewSettings: { proofreadRules: [] }, + kosync: { enabled: false }, + }, + }); + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { + book1: { + viewSettings: { + proofreadRules: [], + }, + }, + }, + }); + + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeTruthy(); + + // Check for empty state messages + expect(screen.getByText('No selected text replacement rules')).toBeTruthy(); + expect(screen.getByText('No book-level replacement rules')).toBeTruthy(); + }); + + it('merges book and library rules correctly in book section', async () => { + const libraryRule: ProofreadRule = { + id: 'l1', + scope: 'library', + pattern: 'library-wide', + replacement: 'LIBRARY', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + }; + + const bookRule: ProofreadRule = { + id: 'b1', + scope: 'book', + pattern: 'book-specific', + replacement: 'BOOK', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 2, + wholeWord: true, + }; + + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { + globalViewSettings: { + proofreadRules: [libraryRule], + }, + kosync: { enabled: false }, + }, + }); + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { + book1: { + viewSettings: { + proofreadRules: [bookRule], + }, + }, + }, + }); + + (useBookDataStore.setState as unknown as (state: unknown) => void)({ + booksData: { + book1: { + id: 'book1', + book: null, + file: null, + config: { + viewSettings: { + proofreadRules: [bookRule], + }, + }, + bookDoc: null, + isFixedLayout: false, + }, + }, + }); + + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeTruthy(); + + // Both library and book rules should appear in the Book Specific Rules section + expect(screen.getByText('library-wide')).toBeTruthy(); + expect(screen.getByText('book-specific')).toBeTruthy(); + + // But they should both be under Book Specific Rules section + const bookSection = screen.getByText('Book Specific Rules').parentElement; + expect(within(bookSection!).getByText('library-wide')).toBeTruthy(); + expect(within(bookSection!).getByText('book-specific')).toBeTruthy(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/ReplacementOptions.test.tsx b/apps/readest-app/src/__tests__/components/ReplacementOptions.test.tsx deleted file mode 100644 index a31a6887..00000000 --- a/apps/readest-app/src/__tests__/components/ReplacementOptions.test.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; -import ReplacementOptions from '@/app/reader/components/annotator/ReplacementOptions'; - -describe('ReplacementOptions Component', () => { - // IMPORTANT: ReplacementOptions should ONLY be rendered for EPUB books. - // for non-EPUB formats (PDF, TXT, etc), the button is disabled - // and ReplacementOptions is never rendered/shown to the user. - // - - const mockOnConfirm = vi.fn(); - const mockOnClose = vi.fn(); - - const defaultProps = { - isVertical: false, - style: { left: '100px', top: '100px' }, - selectedText: 'test word', - onConfirm: mockOnConfirm, - onClose: mockOnClose, - }; - - // Note: ReplacementOptions component should only be rendered for EPUB books. - // All tests here implicitly test EPUB book scenarios. - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - cleanup(); - }); - - describe('Rendering', () => { - it('should render all three replacement scope options', () => { - render(); - - expect(screen.getByText('Fix this once')).toBeTruthy(); - expect(screen.getByText('Fix in this book')).toBeTruthy(); - expect(screen.getByText('Fix in library')).toBeTruthy(); - }); - - it('should render the replacement text input field', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - expect(input).toBeTruthy(); - }); - - it('should render the Case Sensitive checkbox', () => { - const { container } = render(); - - expect(screen.getByText('Case Sensitive')).toBeTruthy(); - expect(container.querySelector('input[type="checkbox"]')).toBeTruthy(); - }); - - it('should render the Cancel button', () => { - render(); - - expect(screen.getByText('Cancel')).toBeTruthy(); - }); - - it('should display selected text preview', () => { - render(); - - expect(screen.getByText(/Selected:/)).toBeTruthy(); - expect(screen.getByText(/"test word"/)).toBeTruthy(); - }); - - it('should truncate long selected text in preview', () => { - const longText = 'a'.repeat(100); - render(); - - // Should show truncated version with ellipsis - const preview = screen.getByText(/Selected:/); - expect(preview.parentElement?.textContent).toContain('...'); - }); - }); - - describe('Case Sensitive Checkbox', () => { - it('should be checked by default (case-sensitive)', () => { - const { container } = render(); - - const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement; - expect(checkbox.checked).toBe(true); - }); - - it('should toggle when clicked', async () => { - const { container } = render(); - - const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement; - expect(checkbox.checked).toBe(true); - - fireEvent.click(checkbox); - expect(checkbox.checked).toBe(false); - - fireEvent.click(checkbox); - expect(checkbox.checked).toBe(true); - }); - - it('should pass case sensitivity value to onConfirm when checked', async () => { - render(); - - // Enter replacement text - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - // Checkbox is checked by default (case sensitive = true) - - // Click a scope button - const fixOnceButton = screen.getByText('Fix this once'); - fireEvent.click(fixOnceButton); - - // Should show confirmation dialog - expect(screen.getByText('Confirm Replacement')).toBeTruthy(); - expect(screen.getByText('Yes')).toBeTruthy(); // Case sensitive: Yes - - // Confirm - const confirmButton = screen.getByText('Confirm'); - fireEvent.click(confirmButton); - - expect(mockOnConfirm).toHaveBeenCalledWith({ - replacementText: 'replacement', - caseSensitive: true, - scope: 'once', - }); - }); - - it('should pass case sensitivity value to onConfirm when unchecked', async () => { - const { container } = render(); - - // Enter replacement text - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - // Uncheck the checkbox (default is true, so we click to toggle to false) - const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement; - fireEvent.click(checkbox); - - // Click a scope button - const fixOnceButton = screen.getByText('Fix this once'); - fireEvent.click(fixOnceButton); - - // Confirm - const confirmButton = screen.getByText('Confirm'); - fireEvent.click(confirmButton); - - expect(mockOnConfirm).toHaveBeenCalledWith({ - replacementText: 'replacement', - caseSensitive: false, - scope: 'once', - }); - }); - }); - - describe('Replacement Text Input', () => { - it('should update value when user types', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...') as HTMLInputElement; - fireEvent.change(input, { target: { value: 'new text' } }); - - expect(input.value).toBe('new text'); - }); - - it('should disable scope buttons when input is empty', () => { - render(); - - const fixOnceButton = screen.getByText('Fix this once') as HTMLButtonElement; - const fixInBookButton = screen.getByText('Fix in this book') as HTMLButtonElement; - const fixInLibraryButton = screen.getByText('Fix in library') as HTMLButtonElement; - - expect(fixOnceButton.disabled).toBe(true); - expect(fixInBookButton.disabled).toBe(true); - expect(fixInLibraryButton.disabled).toBe(true); - }); - - it('should enable scope buttons when input has text', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - const fixOnceButton = screen.getByText('Fix this once') as HTMLButtonElement; - const fixInBookButton = screen.getByText('Fix in this book') as HTMLButtonElement; - const fixInLibraryButton = screen.getByText('Fix in library') as HTMLButtonElement; - - expect(fixOnceButton.disabled).toBe(false); - expect(fixInBookButton.disabled).toBe(false); - expect(fixInLibraryButton.disabled).toBe(false); - }); - - it('should trim whitespace from replacement text', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: ' trimmed ' } }); - - // Click a scope button - const fixOnceButton = screen.getByText('Fix this once'); - fireEvent.click(fixOnceButton); - - // Confirm - const confirmButton = screen.getByText('Confirm'); - fireEvent.click(confirmButton); - - expect(mockOnConfirm).toHaveBeenCalledWith( - expect.objectContaining({ - replacementText: 'trimmed', - }), - ); - }); - }); - - describe('Scope Button Click Handlers', () => { - it('should show confirmation dialog when "Fix this once" is clicked', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - const button = screen.getByText('Fix this once'); - fireEvent.click(button); - - expect(screen.getByText('Confirm Replacement')).toBeTruthy(); - expect(screen.getByText('this instance')).toBeTruthy(); - }); - - it('should show confirmation dialog when "Fix in this book" is clicked', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - const button = screen.getByText('Fix in this book'); - fireEvent.click(button); - - expect(screen.getByText('Confirm Replacement')).toBeTruthy(); - expect(screen.getByText('all instances in this book')).toBeTruthy(); - }); - - it('should show confirmation dialog when "Fix in library" is clicked', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - const button = screen.getByText('Fix in library'); - fireEvent.click(button); - - expect(screen.getByText('Confirm Replacement')).toBeTruthy(); - expect(screen.getByText('all instances in your library')).toBeTruthy(); - }); - - it('should call onConfirm with correct scope for "once"', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - fireEvent.click(screen.getByText('Fix this once')); - const confirmButtons = screen.getAllByText('Confirm'); - if (!confirmButtons[0]) { - throw new Error('Confirm button not found'); - } - fireEvent.click(confirmButtons[0]); - - expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'once' })); - }); - - it('should call onConfirm with correct scope for "book"', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - fireEvent.click(screen.getByText('Fix in this book')); - const confirmButtons = screen.getAllByText('Confirm'); - if (!confirmButtons[0]) { - throw new Error('Confirm button not found'); - } - fireEvent.click(confirmButtons[0]); - - expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'book' })); - }); - - it('should call onConfirm with correct scope for "library"', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - fireEvent.click(screen.getByText('Fix in library')); - const confirmButtons = screen.getAllByText('Confirm'); - if (!confirmButtons[0]) { - throw new Error('Confirm button not found'); - } - fireEvent.click(confirmButtons[0]); - - expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'library' })); - }); - }); - - describe('Confirmation Dialog', () => { - it('should display original text in confirmation', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - fireEvent.click(screen.getByText('Fix this once')); - - expect(screen.getByText('"original"')).toBeTruthy(); - }); - - it('should display replacement text in confirmation', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'new text' } }); - - fireEvent.click(screen.getByText('Fix this once')); - - expect(screen.getByText('"new text"')).toBeTruthy(); - }); - - it('should go back to main view when Back is clicked', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - fireEvent.click(screen.getByText('Fix this once')); - expect(screen.getByText('Confirm Replacement')).toBeTruthy(); - - fireEvent.click(screen.getByText('Back')); - - // Should be back to main view - expect(screen.queryByText('Confirm Replacement')).toBeNull(); - expect(screen.getByText('Fix this once')).toBeTruthy(); - }); - - it('should not call onConfirm when Back is clicked', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.change(input, { target: { value: 'replacement' } }); - - fireEvent.click(screen.getByText('Fix this once')); - fireEvent.click(screen.getByText('Back')); - - expect(mockOnConfirm).not.toHaveBeenCalled(); - }); - }); - - describe('Cancel Button', () => { - it('should call onClose when Cancel is clicked', () => { - render(); - - const cancelButton = screen.getByText('Cancel'); - fireEvent.click(cancelButton); - - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - }); - - describe('Click Outside Behavior', () => { - it('should call onClose when clicking outside the menu', () => { - render( -
-
Outside element
- -
, - ); - - const outsideElement = screen.getByTestId('outside'); - fireEvent.mouseDown(outsideElement); - - expect(mockOnClose).toHaveBeenCalled(); - }); - - it('should not call onClose when clicking inside the menu', () => { - render(); - - const input = screen.getByPlaceholderText('Enter replacement text...'); - fireEvent.mouseDown(input); - - expect(mockOnClose).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/readest-app/src/__tests__/components/ReplacementRulesWindow.test.tsx b/apps/readest-app/src/__tests__/components/ReplacementRulesWindow.test.tsx deleted file mode 100644 index b8c170df..00000000 --- a/apps/readest-app/src/__tests__/components/ReplacementRulesWindow.test.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup, within } from '@testing-library/react'; -import React from 'react'; -import { vi } from 'vitest'; - -import { - ReplacementRulesWindow, - setReplacementRulesWindowVisible, -} from '@/app/reader/components/ReplacementRulesWindow'; -import BookMenu from '@/app/reader/components/sidebar/BookMenu'; -import { useSettingsStore } from '@/store/settingsStore'; -import { useReaderStore } from '@/store/readerStore'; -import { useSidebarStore } from '@/store/sidebarStore'; -import { useBookDataStore } from '@/store/bookDataStore'; -import { ReplacementRule } from '@/types/book'; - -// ------------------------------ -// NEXT.JS ROUTER MOCK -// ------------------------------ -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - push: vi.fn(), - replace: vi.fn(), - prefetch: vi.fn(), - }), - useSearchParams: () => ({ - get: () => null, - toString: () => '', - }), -})); - -// ------------------------------ -// TRANSLATION MOCK -// ------------------------------ -vi.mock('@/hooks/useTranslation', () => ({ - useTranslation: () => (key: string) => key, -})); -vi.mock('@/services/translators/cache', () => ({ - initCache: vi.fn(), - loadCacheFromDB: vi.fn(), - pruneCache: vi.fn(), -})); - -// ------------------------------ -// ENV PROVIDER WRAPPER -// ------------------------------ -// mock environment module so EnvProvider uses fake values -vi.mock('@/services/environment', async (importOriginal) => { - const actual = await importOriginal(); - - return { - ...(typeof actual === 'object' && actual !== null ? actual : {}), // keep all real exports (e.g., isTauriAppPlatform) - - default: { - ...(typeof actual === 'object' && - actual !== null && - 'default' in actual && - typeof actual.default === 'object' && - actual.default !== null - ? actual.default - : {}), // keep all real default fields - API_BASE: 'http://localhost', - ENABLE_TRANSLATOR: false, - getAppService: vi.fn().mockResolvedValue(null), - }, - }; -}); - -import { EnvProvider } from '@/context/EnvContext'; - -function renderWithProviders(ui: React.ReactNode) { - return render({ui}); -} - -describe.skip('ReplacementRulesWindow', () => { - beforeEach(() => { - // Reset stores - (useSettingsStore.setState as unknown as (state: unknown) => void)({ - settings: { - globalViewSettings: { replacementRules: [] }, - kosync: { - enabled: false, - }, - }, - }); - (useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} }); - useSidebarStore.setState({ sideBarBookKey: null }); - (useBookDataStore.setState as unknown as (state: unknown) => void)({ booksData: {} }); - }); - - afterEach(() => { - cleanup(); - }); - - it('renders book and global replacement rules from stores', async () => { - // Arrange: populate stores - (useSettingsStore.setState as unknown as (state: unknown) => void)({ - settings: { - globalViewSettings: { - replacementRules: [ - { - id: 'g1', - pattern: 'foo', - replacement: 'bar', - enabled: true, - isRegex: false, - caseSensitive: true, - order: 1, - }, - { - id: 'b1', - pattern: 'hello', - replacement: 'world', - enabled: true, - isRegex: false, - caseSensitive: true, - order: 2, - }, - ], - kosync: { enabled: false }, - }, - }, - }); - - (useReaderStore.setState as unknown as (state: unknown) => void)({ - viewStates: { - book1: { - viewSettings: { - replacementRules: [], - }, - }, - }, - }); - - useSidebarStore.setState({ sideBarBookKey: 'book1' }); - - // Act: render and open dialog - renderWithProviders(); - // wait a tick so the component's effect attaches the event listener - await Promise.resolve(); - // open via helper which dispatches the custom event - setReplacementRulesWindowVisible(true); - - // Assert - const dialog = await screen.findByRole('dialog'); - expect(dialog).toBeTruthy(); - // Global rules - expect(screen.getByText('foo')).toBeTruthy(); - expect(screen.getByText('bar')).toBeTruthy(); - expect(screen.getByText('hello')).toBeTruthy(); - expect(screen.getByText('world')).toBeTruthy(); - }); - - it('renders single-instance rules separately from book/global rules', async () => { - // Arrange: populate stores with a single rule persisted in book config - (useSettingsStore.setState as unknown as (state: unknown) => void)({ - settings: { - globalViewSettings: { replacementRules: [] }, - kosync: { enabled: false }, - }, - }); - - const singleRule: ReplacementRule = { - id: 's1', - pattern: 'only-once', - replacement: 'single-hit', - enabled: true, - isRegex: false, - caseSensitive: true, - order: 1, - singleInstance: true, - }; - - const bookRule: ReplacementRule = { - id: 'b1', - pattern: 'book-wide', - replacement: 'book-hit', - enabled: true, - isRegex: false, - caseSensitive: true, - order: 2, - }; - - (useReaderStore.setState as unknown as (state: unknown) => void)({ - viewStates: { - book1: { - viewSettings: { - replacementRules: [singleRule, bookRule], - }, - }, - }, - }); - - (useBookDataStore.setState as unknown as (state: unknown) => void)({ - booksData: { - book1: { - id: 'book1', - book: null, - file: null, - config: { - viewSettings: { - replacementRules: [singleRule, bookRule], - }, - }, - bookDoc: null, - isFixedLayout: false, - }, - }, - }); - - useSidebarStore.setState({ sideBarBookKey: 'book1' }); - - // Act: render and open dialog - renderWithProviders(); - await Promise.resolve(); - setReplacementRulesWindowVisible(true); - - // Assert - const dialog = await screen.findByRole('dialog'); - expect(dialog).toBeTruthy(); - - // Single-instance section - expect(screen.getByText('Single Instance Rules')).toBeTruthy(); - expect(screen.getByText('only-once')).toBeTruthy(); - expect(screen.getByText('single-hit')).toBeTruthy(); - - // Book section should still show book-wide rule - expect(screen.getByText('book-wide')).toBeTruthy(); - expect(screen.getByText('book-hit')).toBeTruthy(); - }); - - it('opens when BookMenu item is clicked (integration)', async () => { - // Arrange stores - (useSettingsStore.setState as unknown as (state: unknown) => void)({ - settings: { - globalViewSettings: { replacementRules: [] }, - kosync: { enabled: false }, - }, - }); - (useReaderStore.setState as unknown as (state: unknown) => void)({ - viewStates: { - book1: { viewSettings: { replacementRules: [] } }, - }, - }); - useSidebarStore.setState({ sideBarBookKey: 'book1' }); - - // Render both menu and window - renderWithProviders( -
- - -
, - ); - - // wait a tick so effects attach - await Promise.resolve(); - - // Click the menu item - const menuItem = screen.getByRole('menuitem', { name: 'Replacement Rules' }); - fireEvent.click(menuItem); - - // The dialog should open - const dialog = await screen.findByRole('dialog'); - - expect(within(dialog).getByText('Replacement Rules')).toBeTruthy(); - }); -}); diff --git a/apps/readest-app/src/__tests__/utils/replacement.test.ts b/apps/readest-app/src/__tests__/utils/replacement.test.ts index 580c605c..d5df80f0 100644 --- a/apps/readest-app/src/__tests__/utils/replacement.test.ts +++ b/apps/readest-app/src/__tests__/utils/replacement.test.ts @@ -10,7 +10,7 @@ vi.mock('@/services/translators/cache', () => ({ vi.mock('@/store/settingsStore', () => { const mockState = { settings: { - globalViewSettings: { replacementRules: [] }, + globalViewSettings: { proofreadRules: [] }, globalReadSettings: {}, kosync: { enabled: false }, }, @@ -35,7 +35,7 @@ vi.mock('@/store/settingsStore', () => { vi.mock('@/store/readerStore', () => { const mockState = { - getViewSettings: () => ({ replacementRules: [] }), + getViewSettings: () => ({ proofreadRules: [] }), setViewSettings: vi.fn(), }; @@ -75,26 +75,23 @@ vi.mock('@/store/bookDataStore', () => { return { useBookDataStore: fn }; }); -import { replacementTransformer } from '@/services/transformers/replacement'; +import { proofreadTransformer } from '@/services/transformers/proofread'; import { TransformContext } from '@/services/transformers/types'; -import { ViewSettings, ReplacementRule } from '@/types/book'; -import { - createReplacementRule, - mergeReplacementRules, - validateReplacementRulePattern, -} from '@/services/transformers/replacement'; +import { ViewSettings, ProofreadRule } from '@/types/book'; +import { validateReplacementRulePattern } from '@/store/proofreadStore'; -describe('replacementTransformer', () => { +describe('proofreadTransformer', () => { afterEach(() => { vi.restoreAllMocks(); }); const createMockContext = ( - rules: ReplacementRule[] | undefined, + rules: ProofreadRule[] | undefined, content: string, + sectionHref?: string, ): TransformContext => { const viewSettings = { - replacementRules: rules, + proofreadRules: rules, } as Partial as ViewSettings; return { @@ -102,101 +99,136 @@ describe('replacementTransformer', () => { viewSettings, userLocale: 'en', content, - transformers: ['replacement'], + sectionHref, + transformers: ['proofread'], }; }; describe('basic functionality', () => { test('should return content unchanged when no rules', async () => { const ctx = createMockContext(undefined, '

Hello world

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); expect(result).toContain('Hello world'); }); test('should return content unchanged when rules array is empty', async () => { const ctx = createMockContext([], '

Hello world

'); - const result = await replacementTransformer.transform(ctx); - + const result = await proofreadTransformer.transform(ctx); expect(result).toContain('Hello world'); }); - test('should apply simple string replacement', async () => { - const rules: ReplacementRule[] = [ + test('should apply simple string replacement with whole-word matching', async () => { + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'Hello', replacement: 'Hi', enabled: true, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

Hello world

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); expect(result).toContain('Hi world'); expect(result).not.toContain('Hello'); }); test('should apply multiple simple replacements', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'cat', replacement: 'dog', enabled: true, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, { id: '2', + scope: 'book', pattern: 'The', replacement: 'A', enabled: true, isRegex: false, + caseSensitive: true, order: 2, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

The cat sat

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); expect(result).toContain('A dog sat'); }); test('should replace all occurrences, not just first', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'the', replacement: 'THE', enabled: true, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

the cat and the dog

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); expect(result).toContain('THE cat and THE dog'); }); + + test('should not replace partial word matches with whole-word enabled', async () => { + const rules: ProofreadRule[] = [ + { + id: '1', + scope: 'book', + pattern: 'cat', + replacement: 'dog', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + }, + ]; + const ctx = createMockContext(rules, '

The cat sat on the category

'); + const result = await proofreadTransformer.transform(ctx); + + expect(result).toContain('dog'); + expect(result).toContain('category'); // Should not replace "cat" in "category" + }); }); describe('regex functionality', () => { test('should apply regex replacement', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: '\\d+', replacement: 'NUMBER', enabled: true, isRegex: true, + caseSensitive: true, order: 1, + wholeWord: false, }, ]; const ctx = createMockContext(rules, '

I have 5 apples and 10 oranges

'); - const result = await replacementTransformer.transform(ctx); - expect(result).toContain('NUMBER'); - // Check that numbers in the body content are replaced (not in XML namespace URLs) + const result = await proofreadTransformer.transform(ctx); + const parser = new DOMParser(); const doc = parser.parseFromString(result, 'text/html'); const bodyText = doc.body?.textContent || ''; @@ -205,587 +237,683 @@ describe('replacementTransformer', () => { }); test('should handle regex with word boundaries', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: '\\bcat\\b', replacement: 'dog', enabled: true, isRegex: true, + caseSensitive: true, order: 1, + wholeWord: false, }, ]; const ctx = createMockContext(rules, '

The cat sat on the category

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); expect(result).toContain('dog'); - expect(result).toContain('category'); // Should not replace "cat" in "category" + expect(result).toContain('category'); }); - test('should handle case-insensitive regex when specified', async () => { - const rules: ReplacementRule[] = [ + test('should handle case-sensitive regex', async () => { + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'the', replacement: 'THE', enabled: true, isRegex: true, - order: 1, caseSensitive: true, + order: 1, + wholeWord: false, }, ]; const ctx = createMockContext(rules, '

The cat and the dog

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); - // Note: Our implementation uses 'g' flag, so it will match "the" but not "The" - // This is expected behavior - regex is case-sensitive by default expect(result).toContain('THE'); expect(result).toContain('The cat'); // uppercase "The" stays untouched }); + + test('should handle case-insensitive regex when specified', async () => { + const rules: ProofreadRule[] = [ + { + id: '1', + scope: 'book', + pattern: 'the', + replacement: 'THE', + enabled: true, + isRegex: true, + caseSensitive: false, + order: 1, + wholeWord: false, + }, + ]; + const ctx = createMockContext(rules, '

The cat and the dog

'); + const result = await proofreadTransformer.transform(ctx); + + // Both "The" and "the" should be replaced + expect(result).toContain('THE cat and THE dog'); + }); }); - describe('case sensitivity (single instance)', () => { - test('should be case-sensitive by default for single instance', async () => { - const rules: ReplacementRule[] = [ + describe('selection scope', () => { + test('should skip selection rules without matching sectionHref', async () => { + const rules: ProofreadRule[] = [ { id: '1', - pattern: 'hello', - replacement: 'hi', + scope: 'selection', + pattern: 'test', + replacement: 'REPLACED', enabled: true, isRegex: false, - singleInstance: true, - occurrenceIndex: 0, + caseSensitive: true, order: 1, + wholeWord: true, + sectionHref: 'chapter1.html', + cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)', }, ]; - const ctx = createMockContext(rules, '

Hello world hello there

'); - const result = await replacementTransformer.transform(ctx); + const ctx = createMockContext( + rules, + '

test content

', + 'chapter2.html', + ); + const result = await proofreadTransformer.transform(ctx); - // "hello" (lowercase) should match, "Hello" should not - expect(result).toContain('Hello world hi there'); - expect(result).not.toContain('hi world hi there'); + // Should not replace because sectionHref doesn't match + expect(result).toContain('test content'); + expect(result).not.toContain('REPLACED'); }); - test('should replace case-sensitive match at correct occurrence', async () => { - const rules: ReplacementRule[] = [ + test('should process selection rules with matching sectionHref', async () => { + const rules: ProofreadRule[] = [ { id: '1', - pattern: 'hello', - replacement: 'hi', + scope: 'selection', + pattern: 'test', + replacement: 'REPLACED', enabled: true, isRegex: false, - singleInstance: true, - occurrenceIndex: 1, // second occurrence + caseSensitive: true, order: 1, + wholeWord: true, + sectionHref: 'chapter1.html', + cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)', }, ]; - const ctx = createMockContext(rules, '

hello world and hello again

'); - const result = await replacementTransformer.transform(ctx); + const ctx = createMockContext( + rules, + '

test content

', + 'chapter1.html', + ); + const result = await proofreadTransformer.transform(ctx); - // Only second "hello" should be replaced - expect(result).toContain('hello world and hi again'); - const helloCount = (result.match(/hello/g) || []).length; - const hiCount = (result.match(/hi/g) || []).length; - expect(helloCount).toBe(1); - expect(hiCount).toBe(1); + expect(result).not.toContain('test content'); + expect(result).toContain('REPLACED'); + }); + + test('should handle selection rules with hash fragments in sectionHref', async () => { + const rules: ProofreadRule[] = [ + { + id: '1', + scope: 'selection', + pattern: 'test', + replacement: 'REPLACED', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + sectionHref: 'chapter1.html#section2', + cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)', + }, + ]; + const ctx = createMockContext( + rules, + '

test content

', + 'chapter1.html#section3', + ); + const result = await proofreadTransformer.transform(ctx); + + expect(result).not.toContain('test content'); + expect(result).toContain('REPLACED'); + }); + + test('should not process selection rules without sectionHref', async () => { + const rules: ProofreadRule[] = [ + { + id: '1', + scope: 'selection', + pattern: 'test', + replacement: 'REPLACED', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)', + // No sectionHref + }, + ]; + const ctx = createMockContext( + rules, + '

test content

', + 'chapter1.html', + ); + const result = await proofreadTransformer.transform(ctx); + + // Selection rules without sectionHref should be skipped + expect(result).toContain('test content'); + }); + + test('should handle invalid CFI gracefully', async () => { + const rules: ProofreadRule[] = [ + { + id: '1', + scope: 'selection', + pattern: 'test', + replacement: 'REPLACED', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + sectionHref: 'chapter1.html', + cfi: 'invalid-cfi', + }, + ]; + const ctx = createMockContext(rules, '

test content

', 'chapter1.html'); + const result = await proofreadTransformer.transform(ctx); + + // Should not crash, content should remain unchanged + expect(result).toBeDefined(); + expect(result).toContain('test'); }); }); describe('case sensitivity (book scope)', () => { test('should be case-sensitive by default for book scope', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'hello', replacement: 'hi', enabled: true, isRegex: false, - singleInstance: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

Hello world hello there Hello

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); // Only lowercase "hello" should match expect(result).toContain('Hello world hi there Hello'); - const helloCount = (result.match(/[Hh]ello/g) || []).length; - const hiCount = (result.match(/hi/g) || []).length; + const helloCount = (result.match(/Hello/g) || []).length; + const hiCount = (result.match(/\bhi\b/g) || []).length; expect(helloCount).toBe(2); // Two "Hello" remain expect(hiCount).toBe(1); // One "hello" replaced }); test('should replace all case-sensitive matches in book scope', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'world', replacement: 'universe', enabled: true, isRegex: false, - singleInstance: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

world and world and World

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); // Both lowercase "world" should match, "World" should not expect(result).toContain('universe and universe and World'); - const worldCount = (result.match(/[Ww]orld/g) || []).length; + const worldCount = (result.match(/World/g) || []).length; const universeCount = (result.match(/universe/g) || []).length; expect(worldCount).toBe(1); // One "World" remains expect(universeCount).toBe(2); }); }); - describe('case sensitivity (global scope)', () => { - test('should be case-sensitive by default for global scope', async () => { - // Simulate global rules via the merged rules mechanism - const globalRules: ReplacementRule[] = [ + describe('case sensitivity (library scope)', () => { + test('should be case-sensitive by default for library scope', async () => { + const rules: ProofreadRule[] = [ { - id: 'global-1', + id: 'library-1', + scope: 'library', pattern: 'book', replacement: 'tome', enabled: true, isRegex: false, - singleInstance: false, - global: true, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; - const ctx = createMockContext(globalRules, '

book and Book and BOOK

'); - const result = await replacementTransformer.transform(ctx); + const ctx = createMockContext(rules, '

book and Book and BOOK

'); + const result = await proofreadTransformer.transform(ctx); // Only lowercase "book" should match expect(result).toContain('tome and Book and BOOK'); - const bookCount = (result.match(/[Bb]ook|BOOK/g) || []).length; + const bookCount = (result.match(/Book|BOOK/g) || []).length; const tomeCount = (result.match(/tome/g) || []).length; expect(bookCount).toBe(2); // "Book" and "BOOK" remain expect(tomeCount).toBe(1); }); - test('should replace all case-sensitive matches across global scope', async () => { - const globalRules: ReplacementRule[] = [ + test('should replace all case-sensitive matches across library scope', async () => { + const rules: ProofreadRule[] = [ { - id: 'global-1', + id: 'library-1', + scope: 'library', pattern: 'test', replacement: 'exam', enabled: true, isRegex: false, - singleInstance: false, - global: true, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; - const ctx = createMockContext(globalRules, '

test and test and Test and TEST

'); - const result = await replacementTransformer.transform(ctx); + const ctx = createMockContext(rules, '

test and test and Test and TEST

'); + const result = await proofreadTransformer.transform(ctx); // Only lowercase "test" should match expect(result).toContain('exam and exam and Test and TEST'); - const testCount = (result.match(/[Tt]est|TEST/g) || []).length; + const testCount = (result.match(/Test|TEST/g) || []).length; const examCount = (result.match(/exam/g) || []).length; expect(testCount).toBe(2); // "Test" and "TEST" remain expect(examCount).toBe(2); }); }); - describe('case sensitivity toggle (single instance)', () => { - test('should respect case-sensitive toggle for single instance', async () => { - const rules: ReplacementRule[] = [ - { - id: '1', - pattern: 'hello', - replacement: 'hi', - enabled: true, - isRegex: false, - singleInstance: true, - occurrenceIndex: 0, - caseSensitive: true, // Explicitly case-sensitive - order: 1, - }, - ]; - const ctx = createMockContext(rules, '

Hello world hello there

'); - const result = await replacementTransformer.transform(ctx); - - // Only exact case match should be replaced - expect(result).toContain('Hello world hi there'); - }); - - test('should respect case-insensitive toggle for single instance (explicit)', async () => { - const rules: ReplacementRule[] = [ - { - id: '1', - pattern: 'hello', - replacement: 'hi', - enabled: true, - isRegex: false, - singleInstance: true, - occurrenceIndex: 0, - caseSensitive: false, // Case-insensitive but will be ignored - order: 1, - }, - ]; - const ctx = createMockContext(rules, '

Hello world hello there

'); - const result = await replacementTransformer.transform(ctx); - - // First match should be replaced, case sensitve is set to true by default for single instance - expect(result).toContain('Hello world hi there'); - const hiCount = (result.match(/hi/g) || []).length; - expect(hiCount).toBe(1); - }); - }); - describe('case sensitivity toggle (book scope)', () => { test('should replace case-sensitive when flag is true', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'test', replacement: 'exam', enabled: true, isRegex: false, - singleInstance: false, - caseSensitive: true, // Case-sensitive + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

test Test TEST

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); // Only lowercase "test" should be replaced expect(result).toContain('exam Test TEST'); }); - test('should replace case-insensitive when flag is false (explicit)', async () => { - const rules: ReplacementRule[] = [ + test('should replace case-insensitive when flag is false', async () => { + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'test', replacement: 'exam', enabled: true, isRegex: false, - singleInstance: false, - caseSensitive: false, // Case-insensitive (explicitly set) + caseSensitive: false, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

test Test TEST

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); // All variants should be replaced expect(result).toContain('exam exam exam'); }); test('should replace all occurrences case-insensitively with toggle', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'hello', replacement: 'hi', enabled: true, isRegex: false, - singleInstance: false, - caseSensitive: false, // Case-insensitive + caseSensitive: false, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

hello Hello HELLO

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); // All should be replaced expect(result).toContain('hi hi hi'); - const hiCount = (result.match(/hi/g) || []).length; + const hiCount = (result.match(/\bhi\b/gi) || []).length; expect(hiCount).toBe(3); }); }); - describe('case sensitivity toggle (global scope)', () => { - test('should be case-sensitive when flag is true in global scope', async () => { - const globalRules: ReplacementRule[] = [ + describe('case sensitivity toggle (library scope)', () => { + test('should be case-sensitive when flag is true in library scope', async () => { + const rules: ProofreadRule[] = [ { - id: 'global-1', + id: 'library-1', + scope: 'library', pattern: 'world', replacement: 'universe', enabled: true, isRegex: false, - singleInstance: false, - caseSensitive: true, // Explicitly case-sensitive - global: true, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; - const ctx = createMockContext(globalRules, '

world World WORLD

'); - const result = await replacementTransformer.transform(ctx); + const ctx = createMockContext(rules, '

world World WORLD

'); + const result = await proofreadTransformer.transform(ctx); // Only lowercase "world" replaced expect(result).toContain('universe World WORLD'); }); - test('should be case-insensitive when flag is false (explicit) in global scope', async () => { - const globalRules: ReplacementRule[] = [ + test('should be case-insensitive when flag is false in library scope', async () => { + const rules: ProofreadRule[] = [ { - id: 'global-1', + id: 'library-1', + scope: 'library', pattern: 'world', replacement: 'universe', enabled: true, isRegex: false, - singleInstance: false, - caseSensitive: false, // Case-insensitive - global: true, + caseSensitive: false, order: 1, + wholeWord: true, }, ]; - const ctx = createMockContext(globalRules, '

world World WORLD

'); - const result = await replacementTransformer.transform(ctx); + const ctx = createMockContext(rules, '

world World WORLD

'); + const result = await proofreadTransformer.transform(ctx); // All should be replaced expect(result).toContain('universe universe universe'); - const universeCount = (result.match(/universe/g) || []).length; + const universeCount = (result.match(/universe/gi) || []).length; expect(universeCount).toBe(3); }); }); describe('scope precedence', () => { - test('single-instance should override book/global for that occurrence', async () => { - const rules: ReplacementRule[] = [ + test('selection should be processed first, then book, then library', async () => { + const rules: ProofreadRule[] = [ { - id: 'single-1', - pattern: 'Hello', - replacement: 'Hi-once', + id: 'library-1', + scope: 'library', + pattern: 'world', + replacement: 'LIBRARY', enabled: true, isRegex: false, - singleInstance: true, - occurrenceIndex: 0, - sectionHref: 'chap1', - order: 1, - }, - { - id: 'book-1', - pattern: 'Hello', - replacement: 'Hi-book', - enabled: true, - isRegex: false, - singleInstance: false, - order: 2, - }, - { - id: 'global-1', - pattern: 'Hello', - replacement: 'Hi-global', - enabled: true, - isRegex: false, - singleInstance: false, - global: true, + caseSensitive: true, order: 3, + wholeWord: true, }, - ]; - - const ctx = createMockContext(rules, '

Hello Hello

'); - // Simulate sectionHref to match single-instance rule - ctx.sectionHref = 'chap1'; - - const result = await replacementTransformer.transform(ctx); - - // First occurrence should use single-instance replacement; second should fall to book (before global) - expect(result).toContain('Hi-once Hi-book'); - expect(result).not.toContain('Hi-global'); - }); - - test('book should win over global for same pattern', async () => { - const rules: ReplacementRule[] = [ { id: 'book-1', + scope: 'book', pattern: 'world', replacement: 'BOOK', enabled: true, isRegex: false, - singleInstance: false, - order: 1, - }, - { - id: 'global-1', - pattern: 'world', - replacement: 'GLOBAL', - enabled: true, - isRegex: false, - singleInstance: false, - global: true, + caseSensitive: true, order: 2, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

world world

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); - // Book-scope replacement should apply; global should not override + // Book-scope replacement should apply first due to scope ordering expect(result).toContain('BOOK BOOK'); - expect(result).not.toContain('GLOBAL'); + expect(result).not.toContain('LIBRARY'); + }); + + test('should respect order within same scope', async () => { + const rules: ProofreadRule[] = [ + { + id: '2', + scope: 'book', + pattern: 'cat', + replacement: 'dog', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 2, + wholeWord: true, + }, + { + id: '1', + scope: 'book', + pattern: 'The', + replacement: 'A', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + }, + ]; + const ctx = createMockContext(rules, '

The cat sat

'); + const result = await proofreadTransformer.transform(ctx); + + // First "The" -> "A" (order 1), then "cat" -> "dog" (order 2) + expect(result).toContain('A dog sat'); }); }); describe('rule ordering', () => { test('should apply rules in order (lower order numbers first)', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '2', + scope: 'book', pattern: 'cat', replacement: 'dog', enabled: true, isRegex: false, + caseSensitive: true, order: 2, + wholeWord: true, }, { id: '1', + scope: 'book', pattern: 'The', replacement: 'A', enabled: true, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

The cat sat

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); - // First "The" -> "A" (order 1), then "cat" -> "dog" (order 2) expect(result).toContain('A dog sat'); }); test('should handle rules with same order', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'a', replacement: 'A', enabled: true, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, { id: '2', + scope: 'book', pattern: 'b', replacement: 'B', enabled: true, isRegex: false, - order: 1, // Same order + caseSensitive: true, + order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

a b c

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); - // Both should be applied (whole-word matching) + // Both should be applied expect(result).toContain('A B c'); }); }); describe('enabled/disabled rules', () => { test('should skip disabled rules', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'Hello', replacement: 'Hi', enabled: false, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

Hello world

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); expect(result).toContain('Hello'); expect(result).not.toContain('Hi'); }); test('should only apply enabled rules', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'Hello', replacement: 'Hi', enabled: false, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, { id: '2', + scope: 'book', pattern: 'world', replacement: 'universe', enabled: true, isRegex: false, + caseSensitive: true, order: 2, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

Hello world

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); - expect(result).toContain('Hello'); // Not replaced (disabled) - expect(result).toContain('universe'); // Replaced (enabled) + expect(result).toContain('Hello'); + expect(result).toContain('universe'); }); }); describe('error handling', () => { test('should handle invalid regex gracefully', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: '[invalid', replacement: 'fixed', enabled: true, isRegex: true, + caseSensitive: true, order: 1, + wholeWord: false, }, ]; const ctx = createMockContext(rules, '

Test content

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); - expect(result).toContain('Test content'); // Content unchanged + expect(result).toContain('Test content'); }); test('should continue processing other rules after invalid regex', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: '[invalid', replacement: 'fixed', enabled: true, isRegex: true, + caseSensitive: true, order: 1, + wholeWord: false, }, { id: '2', + scope: 'book', pattern: 'Test', replacement: 'PASSED', enabled: true, isRegex: false, + caseSensitive: true, order: 2, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

Test content

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); - expect(result).toContain('PASSED'); // Second rule should still work + expect(result).toContain('PASSED'); }); }); describe('HTML preservation', () => { test('should preserve HTML structure', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'text', replacement: 'TEXT', enabled: true, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

Some text here

More text'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); expect(result).toContain('

'); expect(result).toContain('

'); @@ -795,21 +923,24 @@ describe('replacementTransformer', () => { }); test('should skip script and style tags', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'text', replacement: 'TEXT', enabled: true, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext( rules, '

Some text

', ); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); // Text in

should be replaced expect(result).toContain('Some TEXT'); @@ -821,145 +952,132 @@ describe('replacementTransformer', () => { describe('unicode and special characters', () => { test('should handle unicode characters', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'café', replacement: 'cafe', enabled: true, isRegex: false, + caseSensitive: true, order: 1, wholeWord: true, }, ]; const ctx = createMockContext(rules, '

café

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); expect(result).toContain('cafe'); expect(result).not.toContain('café'); }); test('should handle special regex characters in simple mode', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'a.b', replacement: 'A.B', enabled: true, - isRegex: false, // Simple mode should escape special chars + isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

a.b and aXb

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); - expect(result).toContain('A.B'); // Exact match replaced - expect(result).toContain('aXb'); // Not replaced (not exact match) - }); - - test('should handle special regex characters in simple mode (single-instance)', async () => { - const rules: ReplacementRule[] = [ - { - id: '1', - pattern: 'a.b', - replacement: 'A.B', - enabled: true, - isRegex: false, // literal match - singleInstance: true, // only first occurrence - occurrenceIndex: 0, // replace only the first one - order: 1, - }, - ]; - - const input = '

aXb and a.b and a.b

'; - - const ctx = createMockContext(rules, input); - const result = await replacementTransformer.transform(ctx); - - // Only the first literal "a.b" should be replaced → "A.B" - expect(result).toContain('A.B and a.b'); // second "a.b" remains - - // aXb must NOT be replaced (the "." does NOT match X) + expect(result).toContain('A.B'); expect(result).toContain('aXb'); - - // Ensure only *one* instance was replaced - const replacedCount = (result.match(/A\.B/g) || []).length; - expect(replacedCount).toBe(1); }); }); describe('edge cases', () => { test('should handle empty pattern', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: '', replacement: 'X', enabled: true, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

test

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); - // empty pattern produces no changes + // empty pattern produces no changes (filtered out by .filter(r => r.pattern.trim())) expect(result).toBe(ctx.content); }); test('should handle empty replacement', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'test', replacement: '', enabled: true, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

test content

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); expect(result).not.toContain('test'); - expect(result).toContain(' content'); + expect(result).toContain('content'); }); test('should handle rules with undefined order', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'test', replacement: 'TEST', enabled: true, isRegex: false, + caseSensitive: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any order: undefined as any, + wholeWord: true, }, ]; const ctx = createMockContext(rules, '

test

'); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); expect(result).toContain('TEST'); }); test('should handle complex HTML with nested elements', async () => { - const rules: ReplacementRule[] = [ + const rules: ProofreadRule[] = [ { id: '1', + scope: 'book', pattern: 'text', replacement: 'TEXT', enabled: true, isRegex: false, + caseSensitive: true, order: 1, + wholeWord: true, }, ]; const ctx = createMockContext( rules, '

Some text

More text here
', ); - const result = await replacementTransformer.transform(ctx); + const result = await proofreadTransformer.transform(ctx); expect(result).toContain('Some TEXT'); expect(result).toContain('More TEXT'); @@ -968,188 +1086,27 @@ describe('replacementTransformer', () => { }); }); - describe('logging', () => { - test('should log transformer call with correct information', async () => { - const rules: ReplacementRule[] = [ - { - id: '1', - pattern: 'test', - replacement: 'TEST', - enabled: true, - isRegex: false, - order: 1, - }, - ]; - const ctx = createMockContext(rules, '

test

'); - - await replacementTransformer.transform(ctx); - }); - }); - - describe('replacement rule management functions', () => { - describe('createReplacementRule', () => { - test('should create a rule with default values', () => { - const rule = createReplacementRule({ - pattern: 'test', - replacement: 'TEST', - }); - - expect(rule).toMatchObject({ - pattern: 'test', - replacement: 'TEST', - isRegex: false, - enabled: true, - order: 1000, - }); - expect(rule.id).toBeDefined(); - expect(typeof rule.id).toBe('string'); - }); - - test('should create a rule with custom values', () => { - const rule = createReplacementRule({ - pattern: '\\d+', - replacement: 'NUMBER', - isRegex: true, - enabled: false, - order: 1, - }); - - expect(rule).toMatchObject({ - pattern: '\\d+', - replacement: 'NUMBER', - isRegex: true, - enabled: false, - order: 1, - }); - }); + describe('validateReplacementRulePattern', () => { + test('should validate simple string pattern', () => { + const result = validateReplacementRulePattern('test', false); + expect(result.valid).toBe(true); }); - describe('mergeReplacementRules', () => { - test('should merge global and book rules', () => { - const globalRules: ReplacementRule[] = [ - { - id: 'global-1', - pattern: 'the', - replacement: 'THE', - enabled: true, - isRegex: false, - order: 1, - }, - ]; - - const bookRules: ReplacementRule[] = [ - { - id: 'book-1', - pattern: 'cat', - replacement: 'dog', - enabled: true, - isRegex: false, - order: 2, - }, - ]; - - const merged = mergeReplacementRules(globalRules, bookRules); - - expect(merged).toHaveLength(2); - expect(merged[0]!.id).toBe('global-1'); - expect(merged[1]!.id).toBe('book-1'); - }); - - test('should prioritize book rules over global rules with same ID', () => { - const globalRules: ReplacementRule[] = [ - { - id: 'rule-1', - pattern: 'the', - replacement: 'THE', - enabled: true, - isRegex: false, - order: 1, - }, - ]; - - const bookRules: ReplacementRule[] = [ - { - id: 'rule-1', - pattern: 'the', - replacement: '>THE<', - enabled: true, - isRegex: false, - order: 1, - }, - ]; - - const merged = mergeReplacementRules(globalRules, bookRules); - - expect(merged).toHaveLength(1); - expect(merged[0]!.replacement).toBe('>THE<'); // Book rule wins - }); - - test('should sort rules by order', () => { - const globalRules: ReplacementRule[] = [ - { - id: 'rule-3', - pattern: 'c', - replacement: 'C', - enabled: true, - isRegex: false, - order: 3, - }, - { - id: 'rule-1', - pattern: 'a', - replacement: 'A', - enabled: true, - isRegex: false, - order: 1, - }, - ]; - - const bookRules: ReplacementRule[] = [ - { - id: 'rule-2', - pattern: 'b', - replacement: 'B', - enabled: true, - isRegex: false, - order: 2, - }, - ]; - - const merged = mergeReplacementRules(globalRules, bookRules); - - expect(merged[0]!.pattern).toBe('a'); - expect(merged[1]!.pattern).toBe('b'); - expect(merged[2]!.pattern).toBe('c'); - }); - - test('should handle undefined rules', () => { - const merged = mergeReplacementRules(undefined, undefined); - expect(merged).toEqual([]); - }); + test('should reject empty pattern', () => { + const result = validateReplacementRulePattern('', false); + expect(result.valid).toBe(false); + expect(result.error).toContain('empty'); }); - describe('validateReplacementRulePattern', () => { - test('should validate simple string pattern', () => { - const result = validateReplacementRulePattern('test', false); - expect(result.valid).toBe(true); - }); + test('should validate valid regex pattern', () => { + const result = validateReplacementRulePattern('\\d+', true); + expect(result.valid).toBe(true); + }); - test('should reject empty pattern', () => { - const result = validateReplacementRulePattern('', false); - expect(result.valid).toBe(false); - expect(result.error).toContain('empty'); - }); - - test('should validate valid regex pattern', () => { - const result = validateReplacementRulePattern('\\d+', true); - expect(result.valid).toBe(true); - }); - - test('should reject invalid regex pattern', () => { - const result = validateReplacementRulePattern('[invalid', true); - expect(result.valid).toBe(false); - expect(result.error).toBeDefined(); - }); + test('should reject invalid regex pattern', () => { + const result = validateReplacementRulePattern('[invalid', true); + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); }); }); }); diff --git a/apps/readest-app/src/__tests__/utils/simplecc.test.ts b/apps/readest-app/src/__tests__/utils/simplecc.test.ts index 5df5217e..ed87f551 100644 --- a/apps/readest-app/src/__tests__/utils/simplecc.test.ts +++ b/apps/readest-app/src/__tests__/utils/simplecc.test.ts @@ -7,7 +7,7 @@ describe.concurrent('suite', () => { beforeAll(async () => { const wasmPath = join(process.cwd(), 'public/vendor/simplecc/simplecc_wasm_bg.wasm'); const wasmBuffer = await readFile(wasmPath); - await init(wasmBuffer); + await init({ module_or_path: wasmBuffer }); }); test('basic s2t and t2s', () => { diff --git a/apps/readest-app/src/__tests__/utils/wordLimit.test.ts b/apps/readest-app/src/__tests__/utils/word.test.ts similarity index 82% rename from apps/readest-app/src/__tests__/utils/wordLimit.test.ts rename to apps/readest-app/src/__tests__/utils/word.test.ts index ed4f53b1..7ae08fdf 100644 --- a/apps/readest-app/src/__tests__/utils/wordLimit.test.ts +++ b/apps/readest-app/src/__tests__/utils/word.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getWordCount, isWordLimitExceeded } from '../../utils/wordLimit'; +import { getWordCount } from '../../utils/word'; describe('Word Limit Feature', () => { describe('getWordCount', () => { @@ -49,49 +49,6 @@ describe('Word Limit Feature', () => { const thirtyOneWords = Array(31).fill('word').join(' '); expect(getWordCount(thirtyOneWords)).toBe(31); }); - }); - - describe('isWordLimitExceeded', () => { - it('should return false for text under 30 words', () => { - expect(isWordLimitExceeded('hello world')).toBe(false); - expect(isWordLimitExceeded('a')).toBe(false); - }); - - it('should return false for exactly 30 words', () => { - const thirtyWords = Array(30).fill('word').join(' '); - expect(isWordLimitExceeded(thirtyWords)).toBe(false); - }); - - it('should return true for 31 words', () => { - const thirtyOneWords = Array(31).fill('word').join(' '); - expect(isWordLimitExceeded(thirtyOneWords)).toBe(true); - }); - - it('should return true for many words', () => { - const manyWords = Array(100).fill('word').join(' '); - expect(isWordLimitExceeded(manyWords)).toBe(true); - }); - - it('should return false for empty string', () => { - expect(isWordLimitExceeded('')).toBe(false); - }); - }); - - describe('Edge cases', () => { - it('should handle very long words', () => { - const longWord = 'a'.repeat(1000); - expect(getWordCount(longWord)).toBe(1); - expect(isWordLimitExceeded(longWord)).toBe(false); - }); - - it('should handle mixed content with newlines', () => { - const text = `Line one with words. - Line two with more words. - Line three.`; - // "Line one with words." = 4, "Line two with more words." = 5, "Line three." = 2 = 11 total - expect(getWordCount(text)).toBe(11); - expect(isWordLimitExceeded(text)).toBe(false); - }); it('should handle unicode characters', () => { expect(getWordCount('你好 世界')).toBe(2); @@ -104,6 +61,21 @@ describe('Word Limit Feature', () => { expect(getWordCount('chapter 1 section 2')).toBe(4); }); }); + + describe('Edge cases', () => { + it('should handle very long words', () => { + const longWord = 'a'.repeat(1000); + expect(getWordCount(longWord)).toBe(1); + }); + + it('should handle mixed content with newlines', () => { + const text = `Line one with words. + Line two with more words. + Line three.`; + // "Line one with words." = 4, "Line two with more words." = 5, "Line three." = 2 = 11 total + expect(getWordCount(text)).toBe(11); + }); + }); }); describe('Case Sensitivity Matching', () => { diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 59a55f09..705ba80b 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -141,6 +141,7 @@ const FoliateViewer: React.FC<{ primaryLanguage: bookData.book?.primaryLanguage, userLocale: getLocale(), content: data, + sectionHref: detail.name, transformers: [ 'style', 'punctuation', @@ -149,9 +150,8 @@ const FoliateViewer: React.FC<{ 'language', 'sanitizer', 'simplecc', - 'replacement', + 'proofread', ], - sectionHref: detail.name, // Pass section href for single-instance replacements }; return Promise.resolve(transformContent(ctx)); } diff --git a/apps/readest-app/src/app/reader/components/ProofreadRules.tsx b/apps/readest-app/src/app/reader/components/ProofreadRules.tsx new file mode 100644 index 00000000..46a2e141 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/ProofreadRules.tsx @@ -0,0 +1,307 @@ +import clsx from 'clsx'; +import React, { useEffect, useState } from 'react'; +import { RiEditLine, RiDeleteBin7Line } from 'react-icons/ri'; +import { useEnv } from '@/context/EnvContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useReaderStore } from '@/store/readerStore'; +import { useSidebarStore } from '@/store/sidebarStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useProofreadStore } from '@/store/proofreadStore'; +import { ProofreadRule, ProofreadScope } from '@/types/book'; +import { eventDispatcher } from '@/utils/event'; +import Dialog from '@/components/Dialog'; + +const dialogId = 'proofread_rules_window'; + +export const setProofreadRulesVisibility = (visible: boolean) => { + const dialog = document.getElementById(dialogId); + if (dialog) { + dialog.dispatchEvent(new CustomEvent('setProofreadRulesVisibility', { detail: { visible } })); + } +}; + +const RuleItem: React.FC<{ + rule: ProofreadRule; + scope: ProofreadScope; + isEditing: boolean; + editingData: { pattern: string; replacement: string; enabled: boolean }; + onEdit: () => void; + onDelete: () => void; + onSave: () => void; + onCancel: () => void; + onEditChange: (field: 'replacement', value: string) => void; +}> = ({ + rule, + scope, + isEditing, + editingData, + onEdit, + onDelete, + onSave, + onCancel, + onEditChange, +}) => { + const _ = useTranslation(); + const { sideBarBookKey } = useSidebarStore(); + const { getView } = useReaderStore(); + + const navigateToSelection = () => { + if (!sideBarBookKey || !rule.cfi) return; + eventDispatcher.dispatch('navigate', { bookKey: sideBarBookKey, cfi: rule.cfi }); + getView(sideBarBookKey)?.goTo(rule.cfi); + }; + + if (isEditing) { + return ( +
+
+ + +
+ +
+ + onEditChange('replacement', e.target.value)} + /> +
+ +
+ + +
+
+ ); + } + + return ( +
+
+
{rule.pattern}
+
+ + {_('Replace with:')} + + {rule.replacement} +
+
+ + {_('Scope:')} + + {scope === 'selection' ? _('Selection') : scope === 'book' ? _('Book') : _('Library')} + + + + + {_('Case sensitive:')} + + {rule.caseSensitive !== false ? _('Yes') : _('No')} + + +
+
+
+ + +
+
+ ); +}; + +// Hook to manage rules logic +const useReplacementRules = (bookKey: string | null) => { + const { settings } = useSettingsStore(); + const { getViewSettings } = useReaderStore(); + const { getConfig } = useBookDataStore(); + + const viewSettings = bookKey ? getViewSettings(bookKey) : null; + const inMemoryRules = viewSettings?.proofreadRules || []; + const persistedConfig = bookKey ? getConfig(bookKey) : null; + const persistedBookRules = persistedConfig?.viewSettings?.proofreadRules || []; + + // Prefer persisted rules; fall back to in-memory + const bookRuleSource = persistedBookRules.length ? persistedBookRules : inMemoryRules; + + const singleRules = bookRuleSource.filter((r: ProofreadRule) => r.scope === 'selection'); + const bookScopedRules = bookRuleSource.filter((r: ProofreadRule) => r.scope === 'book'); + const globalRules = settings?.globalViewSettings?.proofreadRules || []; + + // Merge book-scoped and global rules + const globalRuleIds = new Set(globalRules.map((gr: ProofreadRule) => gr.id)); + + // Remove orphaned overrides (disabled global rules that no longer exist) + const validBookRules = bookScopedRules.filter( + (br: ProofreadRule) => br.enabled !== false || globalRuleIds.has(br.id), + ); + + const mergedBookRules = validBookRules.concat( + globalRules.filter( + (gr: ProofreadRule) => !validBookRules.find((br: ProofreadRule) => br.id === gr.id), + ), + ); + + return { singleRules, bookRules: mergedBookRules }; +}; + +export const ProofreadRulesManager: React.FC = () => { + const _ = useTranslation(); + const { envConfig } = useEnv(); + const { recreateViewer } = useReaderStore(); + const { sideBarBookKey } = useSidebarStore(); + const { updateRule, removeRule } = useProofreadStore(); + + const [isOpen, setIsOpen] = useState(false); + const [editing, setEditing] = useState<{ + id: string | null; + scope: ProofreadScope | null; + pattern: string; + replacement: string; + enabled: boolean; + }>({ id: null, scope: null, pattern: '', replacement: '', enabled: true }); + + const { singleRules, bookRules } = useReplacementRules(sideBarBookKey); + + useEffect(() => { + const handleVisibility = (event: CustomEvent) => setIsOpen(!!event.detail?.visible); + const el = document.getElementById(dialogId); + el?.addEventListener('setProofreadRulesVisibility', handleVisibility as EventListener); + return () => + el?.removeEventListener('setProofreadRulesVisibility', handleVisibility as EventListener); + }, []); + + const startEdit = (rule: ProofreadRule, scope: ProofreadScope) => { + setEditing({ + id: rule.id, + scope, + pattern: rule.pattern, + replacement: rule.replacement, + enabled: !!rule.enabled, + }); + }; + + const cancelEdit = () => { + setEditing({ id: null, scope: null, pattern: '', replacement: '', enabled: true }); + }; + + const saveEdit = async () => { + if (!editing.id || !editing.scope || !sideBarBookKey) return; + + await updateRule(envConfig, sideBarBookKey, editing.id, { + scope: editing.scope, + pattern: editing.pattern, + replacement: editing.replacement, + enabled: editing.enabled, + }); + + cancelEdit(); + recreateViewer(envConfig, sideBarBookKey); + }; + + const deleteRule = async (ruleId: string, scope: ProofreadScope) => { + if (!sideBarBookKey) return; + await removeRule(envConfig, sideBarBookKey, ruleId, scope); + recreateViewer(envConfig, sideBarBookKey); + }; + + const renderRuleList = ( + rules: ProofreadRule[], + scopeType: ProofreadScope, + title: string, + emptyMessage: string, + ) => ( +
+

{title}

+ {rules.length === 0 ? ( +
+

{emptyMessage}

+
+ ) : ( +
    + {rules.map((rule) => ( +
  • + startEdit(rule, scopeType === 'selection' ? 'selection' : rule.scope)} + onDelete={() => + deleteRule(rule.id, scopeType === 'selection' ? 'selection' : rule.scope) + } + onSave={saveEdit} + onCancel={cancelEdit} + onEditChange={(_, value) => setEditing({ ...editing, replacement: value })} + /> +
  • + ))} +
+ )} +
+ ); + + return ( + setIsOpen(false)} + title={_('Proofread Replacement Rules')} + boxClassName='sm:!min-w-[560px] sm:!max-w-[640px] sm:h-auto' + > + {isOpen && ( +
+ {renderRuleList( + singleRules, + 'selection', + _('Selected Text Rules'), + _('No selected text replacement rules'), + )} + {renderRuleList( + bookRules, + 'book', + _('Book Specific Rules'), + _('No book-level replacement rules'), + )} +
+
+ )} +
+ ); +}; + +export default ProofreadRulesManager; diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index 8d0c1b03..9a365860 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -23,7 +23,7 @@ import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge'; import { AboutWindow } from '@/components/AboutWindow'; import { UpdaterWindow } from '@/components/UpdaterWindow'; import { KOSyncSettingsWindow } from './KOSyncSettings'; -import { ReplacementRulesWindow } from './ReplacementRulesWindow'; +import { ProofreadRulesManager } from './ProofreadRules'; import { Toast } from '@/components/Toast'; import { getLocale } from '@/utils/misc'; import { initDayjs } from '@/utils/time'; @@ -161,7 +161,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => { - + diff --git a/apps/readest-app/src/app/reader/components/ReplacementRulesWindow.tsx b/apps/readest-app/src/app/reader/components/ReplacementRulesWindow.tsx deleted file mode 100644 index 9f7b87c3..00000000 --- a/apps/readest-app/src/app/reader/components/ReplacementRulesWindow.tsx +++ /dev/null @@ -1,440 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import Dialog from '@/components/Dialog'; -import { useTranslation } from '@/hooks/useTranslation'; -import { useSettingsStore } from '@/store/settingsStore'; -import { useReaderStore } from '@/store/readerStore'; -import { useSidebarStore } from '@/store/sidebarStore'; -import { useBookDataStore } from '@/store/bookDataStore'; -import { ReplacementRule } from '@/types/book'; -import environmentConfig from '@/services/environment'; -import { updateReplacementRule, removeReplacementRule } from '@/services/transformers/replacement'; -import { eventDispatcher } from '@/utils/event'; -import { RiEditLine, RiDeleteBin7Line } from 'react-icons/ri'; - -export const setReplacementRulesWindowVisible = (visible: boolean) => { - const dialog = document.getElementById('replacement_rules_window'); - if (dialog) { - const event = new CustomEvent('setReplacementRulesVisibility', { - detail: { visible }, - }); - dialog.dispatchEvent(event); - } -}; - -export const ReplacementRulesWindow: React.FC = () => { - const _ = useTranslation(); - const { settings } = useSettingsStore(); - const { getViewSettings } = useReaderStore(); - const { sideBarBookKey } = useSidebarStore(); - const { getConfig } = useBookDataStore(); - - const [isOpen, setIsOpen] = useState(false); - - useEffect(() => { - const handleCustomEvent = (event: CustomEvent) => { - setIsOpen(!!event.detail?.visible); - }; - - const el = document.getElementById('replacement_rules_window'); - el?.addEventListener('setReplacementRulesVisibility', handleCustomEvent as EventListener); - - return () => { - el?.removeEventListener('setReplacementRulesVisibility', handleCustomEvent as EventListener); - }; - }, []); - - const viewSettings = sideBarBookKey ? getViewSettings(sideBarBookKey) : null; - const inMemoryRules = viewSettings?.replacementRules || []; - const persistedConfig = sideBarBookKey ? getConfig(sideBarBookKey) : null; - const persistedBookRules = persistedConfig?.viewSettings?.replacementRules || []; - - // Prefer persisted rules; fall back to in-memory so we show unsaved edits in tests/dev - const bookRuleSource = persistedBookRules.length ? persistedBookRules : inMemoryRules; - - const singleRules = bookRuleSource.filter((r: ReplacementRule) => !!r.singleInstance); - const bookScopedRules = bookRuleSource.filter((r: ReplacementRule) => !r.singleInstance); - - // Book rules = book-scoped rules + global rules (merged for display) - // Merge logic: - // 1. Include all book-scoped rules (including disabled overrides of global rules) - // 2. Include global rules that aren't overridden at book level - // 3. Filter out orphaned overrides (disabled global rules that no longer exist globally) - const globalRules = settings?.globalViewSettings?.replacementRules || []; - - // Create a map of global rule IDs to identify overridden rules - const globalRuleIds = new Set(globalRules.map((gr: ReplacementRule) => gr.id)); - - // Filter out book rules that are disabled overrides of non-existent global rules - const validBookRules = bookScopedRules.filter((br: ReplacementRule) => { - // If it's enabled, it's a real book rule - if (br.enabled !== false) return true; - // If it's disabled and the global rule still exists, keep it (it's an override) - // If the global rule doesn't exist, filter it out (orphaned override) - return globalRuleIds.has(br.id); - }); - - const mergedRules = validBookRules.concat( - globalRules.filter( - (gr: ReplacementRule) => !validBookRules.find((br: ReplacementRule) => br.id === gr.id), - ), - ); - - // Create a map to track the scope of each rule for editing/deleting - const getRuleScope = (rule: ReplacementRule): 'single' | 'book' | 'global' => { - if (rule.singleInstance) return 'single'; - // If the rule is in validBookRules and originates from global, it's an override - return globalRuleIds.has(rule.id) ? 'global' : 'book'; - }; - - const bookRules = mergedRules; - - const [editing, setEditing] = useState<{ - id: string | null; - scope: 'single' | 'book' | 'global' | null; - pattern: string; - replacement: string; - enabled: boolean; - }>({ id: null, scope: null, pattern: '', replacement: '', enabled: true }); - - // Track when a delete/edit operation is in progress to prevent rapid successive operations - const [isReloading, setIsReloading] = useState(false); - - const startEdit = (r: ReplacementRule, scope: 'single' | 'book' | 'global') => { - setEditing({ - id: r.id, - scope, - pattern: r.pattern, - replacement: r.replacement, - enabled: !!r.enabled, - }); - }; - - const cancelEdit = () => - setEditing({ id: null, scope: null, pattern: '', replacement: '', enabled: true }); - - const saveEdit = async () => { - if (!editing.id || !editing.scope) return; - - // Prevent rapid successive operations - if (isReloading) { - eventDispatcher.dispatch('toast', { - type: 'warning', - message: _('Please wait for the current operation to complete.'), - timeout: 3000, - }); - return; - } - setIsReloading(true); - try { - const bookKey = sideBarBookKey || ''; - if (editing.scope === 'global') { - await updateReplacementRule( - environmentConfig, - bookKey, - editing.id, - { - pattern: editing.pattern, - replacement: editing.replacement, - enabled: editing.enabled, - }, - 'global', - ); - } else if (editing.scope === 'book' && sideBarBookKey) { - await updateReplacementRule( - environmentConfig, - sideBarBookKey, - editing.id, - { - pattern: editing.pattern, - replacement: editing.replacement, - enabled: editing.enabled, - }, - 'book', - ); - } else if (editing.scope === 'single' && sideBarBookKey) { - await updateReplacementRule( - environmentConfig, - sideBarBookKey, - editing.id, - { - pattern: editing.pattern, - replacement: editing.replacement, - enabled: editing.enabled, - }, - 'single', - ); - } - cancelEdit(); - eventDispatcher.dispatch('toast', { - type: 'success', - message: _('Replacement rule updated. Reloading book to apply changes...'), - timeout: 3000, - }); - if (sideBarBookKey) { - const { clearViewState, initViewState } = useReaderStore.getState(); - const id = sideBarBookKey.split('-')[0]!; - // Hard reload: clear and reinit viewer to load from original source - clearViewState(sideBarBookKey); - await initViewState(environmentConfig, id, sideBarBookKey, true, true); - } - } catch (err) { - console.error('Failed to save replacement rule', err); - eventDispatcher.dispatch('toast', { - type: 'error', - message: _('Failed to update replacement rule'), - timeout: 3000, - }); - } finally { - setIsReloading(false); - } - }; - - const deleteRule = async (ruleId: string, scope: 'single' | 'book' | 'global') => { - console.log('Deleting rule', ruleId, 'scope', scope); - - // Prevent rapid successive deletions - if (isReloading) { - eventDispatcher.dispatch('toast', { - type: 'warning', - message: _('Please wait for the book to finish reloading.'), - timeout: 3000, - }); - return; - } - setIsReloading(true); - - try { - const bookKey = sideBarBookKey || ''; - - if (scope === 'global') { - // delete global rule for all books - await removeReplacementRule(environmentConfig, '', ruleId, 'global'); - } else { - await removeReplacementRule(environmentConfig, bookKey, ruleId, scope); - } - const successMessage = - scope === 'global' - ? _( - 'Global replacement rule deleted for all books in the library. Reloading book to apply changes...', - ) - : _('Replacement rule deleted. Reloading book to apply changes...'); - - eventDispatcher.dispatch('toast', { - type: 'success', - message: successMessage, - timeout: 3000, - }); - if (sideBarBookKey) { - const { clearViewState, initViewState } = useReaderStore.getState(); - const id = sideBarBookKey.split('-')[0]!; - // Hard reload: clear and reinit viewer to load from original source - clearViewState(sideBarBookKey); - await initViewState(environmentConfig, id, sideBarBookKey, true, true); - } - } catch (err) { - console.error('Failed to delete replacement rule', err); - eventDispatcher.dispatch('toast', { - type: 'error', - message: _('Failed to delete replacement rule'), - timeout: 3000, - }); - } finally { - setIsReloading(false); - } - }; - - return ( - setIsOpen(false)} - title={_('Replacement Rules')} - boxClassName='sm:!min-w-[520px] sm:h-auto' - > - {isOpen && ( -
-
-

{_('Single Instance Rules')}

- {singleRules.length === 0 ? ( -

- {_('No single replacement rules')} -

- ) : ( -
    - {singleRules.map((r) => ( -
  • - {editing.id === r.id && editing.scope === 'single' ? ( -
    -
    - - -
    - -
    - - - setEditing({ ...editing, replacement: e.target.value }) - } - /> -
    - -
    - - -
    -
    - ) : ( -
    -
    -
    {r.pattern}
    -
    - - {_('Replace with:')} - - {r.replacement} -
    -
    - {_('Scope:')} Single Instance -  | {_('Case sensitive:')}  - - {r.caseSensitive !== false ? _('Yes') : _('No')} - -
    -
    -
    - - -
    -
    - )} -
  • - ))} -
- )} - -

{_('Book Specific Rules')}

- {bookRules.length === 0 ? ( -

- {_('No book-level replacement rules')} -

- ) : ( -
    - {bookRules.map((r) => { - const ruleScope = getRuleScope(r); - const isEditing = editing.id === r.id && editing.scope === ruleScope; - return ( -
  • - {isEditing ? ( -
    -
    - - -
    - -
    - - - setEditing({ ...editing, replacement: e.target.value }) - } - /> -
    - -
    - - -
    -
    - ) : ( -
    -
    -
    {r.pattern}
    -
    - - {_('Replace with:')} - - {r.replacement} -
    -
    - {_('Scope:')}  - - {getRuleScope(r) === 'book' ? _('Book') : _('Global')} - -  | {_('Case sensitive:')}  - - {r.caseSensitive !== false ? _('Yes') : _('No')} - -
    -
    -
    - - -
    -
    - )} -
  • - ); - })} -
- )} -
-
- )} -
- ); -}; - -export default ReplacementRulesWindow; 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 0dc8ba7d..539ef721 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -8,7 +8,7 @@ import { RiDeleteBinLine } from 'react-icons/ri'; import { BsTranslate } from 'react-icons/bs'; import { TbHexagonLetterD } from 'react-icons/tb'; import { FaHeadphones } from 'react-icons/fa6'; -import { MdBuildCircle } from 'react-icons/md'; +import { IoIosBuild } from 'react-icons/io'; import * as CFI from 'foliate-js/epubcfi.js'; import { Overlayer } from 'foliate-js/overlayer.js'; @@ -30,15 +30,13 @@ import { findTocItemBS } from '@/utils/toc'; import { throttle } from '@/utils/throttle'; import { runSimpleCC } from '@/utils/simplecc'; import { HIGHLIGHT_COLOR_HEX } from '@/services/constants'; -import { addReplacementRule } from '@/services/transformers/replacement'; +import { getWordCount } from '@/utils/word'; import AnnotationPopup from './AnnotationPopup'; import WiktionaryPopup from './WiktionaryPopup'; import WikipediaPopup from './WikipediaPopup'; import TranslatorPopup from './TranslatorPopup'; import useShortcuts from '@/hooks/useShortcuts'; -import ReplacementOptions from './ReplacementOptions'; - -import { isWordLimitExceeded } from '@/utils/wordLimit'; +import ProofreadPopup from './ProofreadPopup'; const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const _ = useTranslation(); @@ -64,11 +62,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false); const [showWikipediaPopup, setShowWikipediaPopup] = useState(false); const [showDeepLPopup, setShowDeepLPopup] = useState(false); - const [showReplacementOptions, setShowReplacementOptions] = useState(false); + const [showProofreadPopup, setShowProofreadPopup] = useState(false); const [trianglePosition, setTrianglePosition] = useState(); const [annotPopupPosition, setAnnotPopupPosition] = useState(); const [dictPopupPosition, setDictPopupPosition] = useState(); const [translatorPopupPosition, setTranslatorPopupPosition] = useState(); + const [proofreadPopupPosition, setProofreadPopupPosition] = useState(); const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false); const [selectedStyle, setSelectedStyle] = useState( @@ -85,6 +84,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const dictPopupHeight = Math.min(300, maxHeight); const transPopupWidth = Math.min(480, maxWidth); const transPopupHeight = Math.min(265, maxHeight); + const proofreadPopupWidth = Math.min(440, maxWidth); + const proofreadPopupHeight = Math.min(200, maxHeight); const annotPopupWidth = Math.min(useResponsiveSize(300), maxWidth); const annotPopupHeight = useResponsiveSize(44); const androidSelectionHandlerHeight = 0; @@ -121,10 +122,18 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { transPopupHeight, popupPadding, ); + const proofreadPopupPos = getPopupPosition( + triangPos, + rect, + proofreadPopupWidth, + proofreadPopupHeight, + popupPadding, + ); if (triangPos.point.x == 0 || triangPos.point.y == 0) return; setAnnotPopupPosition(annotPopupPos); setDictPopupPosition(dictPopupPos); setTranslatorPopupPosition(transPopupPos); + setProofreadPopupPosition(proofreadPopupPos); setTrianglePosition(triangPos); }, [ selection, @@ -138,6 +147,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { dictPopupHeight, transPopupWidth, transPopupHeight, + proofreadPopupWidth, + proofreadPopupHeight, ]); useEffect(() => { @@ -156,7 +167,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { setShowWiktionaryPopup(false); setShowWikipediaPopup(false); setShowDeepLPopup(false); - setShowReplacementOptions(false); + setShowProofreadPopup(false); }, 500), [], ); @@ -213,7 +224,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const range = sel.getRangeAt(0); const text = sel.toString(); if (text.trim()) { - setSelection({ key: bookKey, text, range, index }); + setSelection({ key: bookKey, text, range, index, cfi: view?.getCFI(index, range) }); // Show translation popup preferentially for PDF right-click setShowAnnotPopup(false); setShowDeepLPopup(true); @@ -270,7 +281,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { ); const annotation = annotations.find((annotation) => annotation.cfi === cfi); if (!annotation) return; - const selection = { key: bookKey, annotated: true, text: annotation.text ?? '', range, index }; + const selection = { + key: bookKey, + annotated: true, + text: annotation.text ?? '', + cfi: view?.getCFI(index, range), + range, + index, + }; setSelectedStyle(annotation.style!); setSelectedColor(annotation.color!); setSelection(selection); @@ -280,16 +298,28 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation }); useEffect(() => { - handleShowPopup(showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup); + handleShowPopup( + showAnnotPopup || + showWiktionaryPopup || + showWikipediaPopup || + showDeepLPopup || + showProofreadPopup, + ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [showAnnotPopup, showWiktionaryPopup, showWikipediaPopup, showDeepLPopup]); + }, [showAnnotPopup, showWiktionaryPopup, showWikipediaPopup, showDeepLPopup, showProofreadPopup]); // When popups are visible, update their positions on scroll events useEffect(() => { const view = getView(bookKey); if (!view?.renderer) return; const onScroll = () => { - if (showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup) { + if ( + showAnnotPopup || + showWiktionaryPopup || + showWikipediaPopup || + showDeepLPopup || + showProofreadPopup + ) { repositionPopups(); } }; @@ -304,6 +334,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { showWiktionaryPopup, showWikipediaPopup, showDeepLPopup, + showProofreadPopup, repositionPopups, ]); @@ -347,10 +378,18 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { transPopupHeight, popupPadding, ); + const proofreadPopupPos = getPopupPosition( + triangPos, + rect, + proofreadPopupWidth, + proofreadPopupHeight, + popupPadding, + ); if (triangPos.point.x == 0 || triangPos.point.y == 0) return; setAnnotPopupPosition(annotPopupPos); setDictPopupPosition(dictPopupPos); setTranslatorPopupPosition(transPopupPos); + setProofreadPopupPosition(proofreadPopupPos); setTrianglePosition(triangPos); handleShowAnnotPopup(); } @@ -389,13 +428,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { setShowWikipediaPopup(false); }; - const handleCopy = (copyToNotebook = true) => { + const handleCopy = (copyToNotebook = true, dismissPopup = true) => { if (!selection || !selection.text) return; setTimeout(() => { // Delay to ensure it won't be overridden by system clipboard actions navigator.clipboard?.writeText(selection.text); }, 100); - handleDismissPopupAndSelection(); + if (dismissPopup) { + handleDismissPopupAndSelection(); + } if (!copyToNotebook) return; @@ -474,7 +515,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { } else { annotations.push(annotation); views.forEach((view) => view?.addAnnotation(annotation)); - setSelection({ ...selection, annotated: true }); + setSelection({ ...selection, cfi, annotated: true }); } const updatedConfig = updateBooknotes(bookKey, annotations); @@ -529,286 +570,19 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range }); }; - // Import type for ReplacementConfig - type ReplacementConfig = { - replacementText: string; - caseSensitive: boolean; - scope: 'once' | 'book' | 'library'; - }; - - // Helper to check if selected text is a whole word (has word boundaries on both sides) - // Updated to be more lenient: allows phrases and lines, only prevents partial word matches - const isWholeWord = (range: Range, selectedText: string): boolean => { - try { - if (!selectedText || selectedText.trim().length === 0) return false; - - // Verify the selection contains word characters - const hasWordCharInSelection = /[a-zA-Z0-9_]/.test(selectedText); - if (!hasWordCharInSelection) { - return false; - } - - // If the selection contains spaces, punctuation, or multiple words, it's a phrase - // Phrases (including lines with quotes) are always allowed for single-instance replacements - const hasSpaces = /\s/.test(selectedText); - const hasPunctuation = /[^\w\s]/.test(selectedText); - const isPhrase = hasSpaces || hasPunctuation; - - // Also allow selections that start or end with punctuation (e.g., "'tis", "off;", "look,") - // These are valid selections where the user intentionally includes punctuation - const startsWithPunctuation = /^[^\w\s]/.test(selectedText); - const endsWithPunctuation = /[^\w\s]$/.test(selectedText); - const hasBoundaryPunctuation = startsWithPunctuation || endsWithPunctuation; - - if (isPhrase || hasBoundaryPunctuation) { - // For phrases or selections with boundary punctuation, we allow them - // The only thing we want to prevent is selecting "and" inside "England" - return true; - } - - // For single words, check boundaries to prevent partial word matches - // Get characters immediately before and after the selection - let charBefore = ''; - let charAfter = ''; - - try { - // Get character before - const startNode = range.startContainer; - if (startNode.nodeType === Node.TEXT_NODE && range.startOffset > 0) { - const textNode = startNode as Text; - charBefore = textNode.textContent?.charAt(range.startOffset - 1) || ''; - } else if (startNode.nodeType === Node.TEXT_NODE && range.startOffset === 0) { - // Check previous sibling text node - let prevSibling = startNode.previousSibling; - while (prevSibling && prevSibling.nodeType !== Node.TEXT_NODE) { - prevSibling = prevSibling.previousSibling; - } - if (prevSibling && prevSibling.nodeType === Node.TEXT_NODE) { - const prevText = (prevSibling as Text).textContent || ''; - charBefore = prevText.charAt(prevText.length - 1); - } - } - - // Get character after - const endNode = range.endContainer; - if (endNode.nodeType === Node.TEXT_NODE) { - const textNode = endNode as Text; - const textContent = textNode.textContent || ''; - if (range.endOffset < textContent.length) { - charAfter = textContent.charAt(range.endOffset); - } else { - // Check next sibling text node - let nextSibling = textNode.nextSibling; - while (nextSibling && nextSibling.nodeType !== Node.TEXT_NODE) { - nextSibling = nextSibling.nextSibling; - } - if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE) { - const nextText = (nextSibling as Text).textContent || ''; - charAfter = nextText.charAt(0); - } - } - } - } catch (e) { - // If we can't determine boundaries for a single word, be lenient - // This handles edge cases with complex HTML - console.warn('[isWholeWord] Error checking boundaries:', e); - return true; // Allow if we can't verify (better to allow than reject valid selections) - } - - // Word characters are: letters, digits, and underscore [a-zA-Z0-9_] - const isWordChar = (char: string) => /[a-zA-Z0-9_]/.test(char); - - // Check boundaries for single words - // Empty means we're at start/end of text (valid boundary) - const hasBoundaryBefore = !charBefore || !isWordChar(charBefore); - const hasBoundaryAfter = !charAfter || !isWordChar(charAfter); - - const isValid = hasBoundaryBefore && hasBoundaryAfter; - - if (!isValid) { - console.log('[isWholeWord] Not a whole word:', { - selectedText, - charBefore: charBefore || '(start)', - charAfter: charAfter || '(end)', - hasBoundaryBefore, - hasBoundaryAfter, - }); - } - - return isValid; - } catch (e) { - console.warn('Failed to check whole word:', e); - // On error, be lenient - allow selections with word characters - // This prevents false rejections for complex selections (quotes, multi-node, etc.) - return /[a-zA-Z0-9_]/.test(selectedText); - } - }; - - // Helper to count which occurrence of a pattern was selected (using whole-word matching) - const getOccurrenceIndex = (range: Range, pattern: string): number => { - try { - const doc = range.startContainer.ownerDocument; - if (!doc || !doc.body) return 0; - - // Create a range from start of body to start of selection - const beforeRange = doc.createRange(); - beforeRange.setStart(doc.body, 0); - beforeRange.setEnd(range.startContainer, range.startOffset); - - // Get text before selection and count occurrences using whole-word matching - const textBefore = beforeRange.toString(); - // Escape pattern and add word boundaries for whole-word matching - const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const wholeWordPattern = `\\b${escapedPattern}\\b`; - const regex = new RegExp(wholeWordPattern, 'g'); - const matches = textBefore.match(regex); - - return matches ? matches.length : 0; - } catch (e) { - console.warn('Failed to get occurrence index:', e); - return 0; - } - }; - - const handleReplacementConfirm = async (config: ReplacementConfig) => { + const handleShowProofreadOptions = () => { if (!selection || !selection.text) return; + setShowAnnotPopup(false); + setShowProofreadPopup(true); - const { replacementText, caseSensitive, scope } = config; - - console.log('Replacement confirmed:', { - originalText: selection.text, - replacementText, - caseSensitive, - scope, - }); - - try { - if (scope === 'once') { - // For single-instance: direct DOM modification + persistent rule - const range = selection.range; - if (range) { - // Validate that the selection is a whole word - // Single-instance replacements only work on whole words to prevent - // replacing substrings inside larger words (e.g., "and" in "England") - const isValidWholeWord = isWholeWord(range, selection.text); - - if (!isValidWholeWord) { - eventDispatcher.dispatch('toast', { - type: 'warning', - message: `Cannot replace "${selection.text}" - please select a complete word. Partial word selections (like "and" in "England" or "errand") are not supported.`, - timeout: 5000, - }); - return; - } - - // Get which occurrence this is BEFORE modifying the DOM - // Use whole-word matching to count occurrences correctly - const occurrenceIndex = getOccurrenceIndex(range, selection.text); - const sectionHref = progress?.sectionHref; - - // Directly modify DOM for immediate effect - // Note: createTextNode automatically escapes HTML entities, so angle brackets will be preserved - range.deleteContents(); - const textNode = document.createTextNode(replacementText); - range.insertNode(textNode); - - // Create rule with occurrence tracking for persistence - await addReplacementRule( - envConfig, - bookKey, - { - pattern: selection.text, - replacement: replacementText, - isRegex: false, - enabled: true, - caseSensitive, - singleInstance: true, - sectionHref, - occurrenceIndex, - }, - 'single', - ); - - eventDispatcher.dispatch('toast', { - type: 'success', - message: 'Replacement applied! Will persist on refresh.', - timeout: 3000, - }); - - setShowReplacementOptions(false); - handleDismissPopupAndSelection(); - } - } else { - // For book-wide and global: use the transformer approach - const backendScope = scope === 'book' ? 'book' : 'global'; - const range = selection.range; - const isValidWholeWord = range ? isWholeWord(range, selection.text) : false; - if (!isValidWholeWord) { - eventDispatcher.dispatch('toast', { - type: 'warning', - message: `Cannot replace "${selection.text}" - please select a complete word. Partial word selections (like "and" in "England" or "errand") are not supported.`, - timeout: 5000, - }); - return; - } - await addReplacementRule( - envConfig, - bookKey, - { - pattern: selection.text, - replacement: replacementText, - isRegex: false, - enabled: true, - caseSensitive, - singleInstance: false, - wholeWord: true, - }, - backendScope as 'book' | 'global', - ); - - const scopeLabels = { - book: 'this book', - library: 'your library', - }; - - eventDispatcher.dispatch('toast', { - type: 'success', - message: `Replacement applied to ${scopeLabels[scope]}! Reloading...`, - timeout: 3000, - }); - - setShowReplacementOptions(false); - handleDismissPopupAndSelection(); - - // Reload the book view to apply the replacement - const { recreateViewer } = useReaderStore.getState(); - await recreateViewer(envConfig, bookKey); - } - } catch (error) { - console.error('Failed to apply replacement:', error); - eventDispatcher.dispatch('toast', { - type: 'error', - message: 'Failed to apply replacement. Please try again.', - timeout: 3000, - }); - } - }; - - const handleShowReplacementOptions = () => { - if (!selection || !selection.text) { - return; - } - - if (isWordLimitExceeded(selection.text)) { + if (getWordCount(selection.text) > 30) { eventDispatcher.dispatch('toast', { type: 'warning', - message: 'Word limit exceeded. Please select 30 words or fewer.', + message: _('Word limit of 30 words exceeded.'), timeout: 3000, }); return; } - - setShowReplacementOptions(!showReplacementOptions); }; // Keyboard shortcuts: trigger actions only if there's an active selection and popup hidden @@ -827,7 +601,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { handleSearch(); }, onCopySelection: () => { - handleCopy(false); + handleCopy(false, false); }, onTranslateSelection: () => { handleTranslation(); @@ -961,11 +735,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { disabled: bookData.book?.format === 'PDF', }, { - tooltipText: 'Text Replacement', - Icon: MdBuildCircle, - onClick: handleShowReplacementOptions, + tooltipText: _('Proofread'), + Icon: IoIosBuild, + onClick: handleShowProofreadOptions, disabled: bookData.book?.format !== 'EPUB', - visible: false, }, ]; @@ -1019,20 +792,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { onDismiss={handleDismissPopupAndSelection} /> )} - {showReplacementOptions && trianglePosition && annotPopupPosition && ( - setShowReplacementOptions(false)} + {showProofreadPopup && trianglePosition && proofreadPopupPosition && selection && ( + )} diff --git a/apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx b/apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx index 51e52340..9cafc17e 100644 --- a/apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx @@ -24,7 +24,7 @@ const PopupButton: React.FC = ({ return (
+
+ + +
+ + +
+ + setReplacementText(e.target.value)} - placeholder='Enter replacement text...' - className='w-full rounded-md bg-gray-600 px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500' - /> -
- - {/* Case sensitivity checkbox */} - - - {/* Scope buttons */} -
- - - -
- - {/* Cancel button */} - -
- ); -}; - -export default ReplacementOptions; 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 76826169..a61a8a04 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx @@ -14,7 +14,7 @@ import { isWebAppPlatform } from '@/services/environment'; import { eventDispatcher } from '@/utils/event'; import { DOWNLOAD_READEST_URL } from '@/services/constants'; import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings'; -import { setReplacementRulesWindowVisible } from '@/app/reader/components/ReplacementRulesWindow'; +import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRules'; import { FIXED_LAYOUT_FORMATS } from '@/types/book'; import useBooksManager from '../../hooks/useBooksManager'; import MenuItem from '@/components/MenuItem'; @@ -79,8 +79,8 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) setKOSyncSettingsWindowVisible(true); setIsDropdownOpen?.(false); }; - const showReplacementRulesWindow = () => { - setReplacementRulesWindowVisible(true); + const showProofreadRulesWindow = () => { + setProofreadRulesVisibility(true); setIsDropdownOpen?.(false); }; const handlePullKOSync = () => { @@ -145,7 +145,6 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) ))}
- {false && } {settings.kosync.enabled && ( <> @@ -153,6 +152,8 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) )}
+ +
{ @@ -65,7 +71,13 @@ export const useTextSelector = ( setTimeout(async () => { if (!isTextSelected.current) return; sel.addRange(range); - setSelection({ key: bookKey, text: await getAnnotationText(range), range, index }); + setSelection({ + key: bookKey, + text: await getAnnotationText(range), + cfi: view?.getCFI(index, range), + range, + index, + }); }, 30); }, 30); }; diff --git a/apps/readest-app/src/services/transformers/index.ts b/apps/readest-app/src/services/transformers/index.ts index 6740c976..0967f708 100644 --- a/apps/readest-app/src/services/transformers/index.ts +++ b/apps/readest-app/src/services/transformers/index.ts @@ -6,7 +6,7 @@ import { whitespaceTransformer } from './whitespace'; import { sanitizerTransformer } from './sanitizer'; import { simpleccTransformer } from './simplecc'; import { styleTransformer } from './style'; -import { replacementTransformer } from './replacement'; +import { proofreadTransformer } from './proofread'; export const availableTransformers: Transformer[] = [ punctuationTransformer, @@ -16,6 +16,6 @@ export const availableTransformers: Transformer[] = [ whitespaceTransformer, sanitizerTransformer, simpleccTransformer, - replacementTransformer, + proofreadTransformer, // Add more transformers here ]; diff --git a/apps/readest-app/src/services/transformers/proofread.ts b/apps/readest-app/src/services/transformers/proofread.ts new file mode 100644 index 00000000..e6be78fe --- /dev/null +++ b/apps/readest-app/src/services/transformers/proofread.ts @@ -0,0 +1,225 @@ +import * as CFI from 'foliate-js/epubcfi.js'; +import type { Transformer } from './types'; +import { ProofreadRule } from '@/types/book'; +import { useSettingsStore } from '@/store/settingsStore'; + +interface NormalizedPattern { + source: string; + flags: string; +} + +const isUnicodeWordChar = (char: string): boolean => /[\p{L}\p{N}_]/u.test(char || ''); +const hasUnicodeChars = (text: string): boolean => /[^\x00-\x7F]/.test(text); + +function normalizePattern( + pattern: string, + isRegex: boolean, + caseSensitive = true, +): NormalizedPattern { + const hasUnicode = hasUnicodeChars(pattern); + + let flags = hasUnicode ? 'ug' : 'g'; + if (!caseSensitive) flags += 'i'; + + if (isRegex) { + const source = pattern.includes('\\b') || hasUnicode ? pattern : `\\b${pattern}\\b`; + return { source, flags }; + } + + const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + if (hasUnicode) { + return { source: escaped, flags }; + } + + const startsWithPunctuation = /^[^\w\s]/.test(pattern); + const endsWithPunctuation = /[^\w\s]$/.test(pattern); + + if (startsWithPunctuation || endsWithPunctuation) { + const wordMatch = pattern.match(/[\w]+/); + if (!wordMatch) return { source: escaped, flags }; + + const wordPart = wordMatch[0]; + const wordStart = pattern.indexOf(wordPart); + const wordEnd = wordStart + wordPart.length; + const wordEscaped = wordPart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const beforeWord = escaped.substring(0, wordStart); + const afterWord = escaped.substring(wordEnd); + return { source: `${beforeWord}\\b${wordEscaped}\\b${afterWord}`, flags }; + } + + return { source: `\\b${escaped}\\b`, flags }; +} + +function isValidMatch(text: string, match: RegExpExecArray, rule: ProofreadRule): boolean { + if (!rule.isRegex) { + const isCaseSensitive = rule.caseSensitive !== false; + const isExactMatch = isCaseSensitive + ? match[0] === rule.pattern + : match[0].toLowerCase() === rule.pattern.toLowerCase(); + if (!isExactMatch) return false; + } + + if (hasUnicodeChars(rule.pattern)) { + const charBefore = text[match.index - 1] ?? ''; + const charAfter = text[match.index + match[0].length] ?? ''; + if (isUnicodeWordChar(charBefore) || isUnicodeWordChar(charAfter)) { + return false; + } + } + + return true; +} + +function applyReplacementMulti( + textNodes: Text[], + rule: ProofreadRule & { normalizedPattern: NormalizedPattern }, +): void { + let regex: RegExp; + try { + regex = new RegExp(rule.normalizedPattern.source, rule.normalizedPattern.flags); + } catch { + return; + } + + for (const textNode of textNodes) { + if (!textNode.textContent) continue; + + let text = textNode.textContent; + const matches: Array<{ index: number; length: number }> = []; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + if (!isValidMatch(text, match, rule)) continue; + matches.push({ index: match.index, length: match[0].length }); + } + + if (matches.length === 0) continue; + + for (let i = matches.length - 1; i >= 0; i--) { + const m = matches[i]!; + text = text.slice(0, m.index) + rule.replacement + text.slice(m.index + m.length); + } + + textNode.textContent = text; + } +} + +function applyReplacementSingle( + doc: Document, + rule: ProofreadRule & { normalizedPattern: NormalizedPattern }, +): void { + let regex: RegExp; + try { + regex = new RegExp(rule.normalizedPattern.source, rule.normalizedPattern.flags); + } catch { + return; + } + + const parts = CFI.parse(rule.cfi); + if (parts.parent) { + parts.parent.shift(); + const range = CFI.toRange(doc, parts) as Range; + if (range) { + const startContainer = range.startContainer; + const endContainer = range.endContainer; + if (startContainer === endContainer && startContainer.nodeType === Node.TEXT_NODE) { + const textNode = startContainer as Text; + const text = textNode.textContent || ''; + const startOffset = range.startOffset; + const endOffset = range.endOffset; + + // Add 32-char tolerance buffer around the selection + const bufferSize = 32; + const bufferStart = Math.max(0, startOffset - bufferSize); + const bufferEnd = Math.min(text.length, endOffset + bufferSize); + + const bufferedText = text.slice(bufferStart, bufferEnd); + const selectedText = text.slice(startOffset, endOffset); + + const match = regex.exec(bufferedText); + if (match && isValidMatch(selectedText, match, rule)) { + const matchStartInBuffer = match.index; + const matchStartInText = bufferStart + matchStartInBuffer; + const matchEnd = matchStartInText + match[0].length; + + if (matchEnd >= startOffset - bufferSize && matchStartInText <= endOffset + bufferSize) { + const newText = + text.slice(0, matchStartInText) + rule.replacement + text.slice(matchEnd); + textNode.textContent = newText; + return; + } + } + } + } + } +} + +function getTextNodes(doc: Document): Text[] { + const walker = document.createTreeWalker(doc.body || doc.documentElement, NodeFilter.SHOW_TEXT, { + acceptNode: (node) => { + const parent = node.parentElement; + if (parent?.tagName === 'SCRIPT' || parent?.tagName === 'STYLE') { + return NodeFilter.FILTER_REJECT; + } + return node.textContent?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + }, + }); + + const textNodes: Text[] = []; + let node: Node | null; + while ((node = walker.nextNode())) { + textNodes.push(node as Text); + } + return textNodes; +} + +export const proofreadTransformer: Transformer = { + name: 'proofread', + + transform: async (ctx) => { + const globalRules = useSettingsStore.getState().settings?.globalViewSettings?.proofreadRules; + const bookRules = ctx.viewSettings.proofreadRules; + const merged = [...(globalRules ?? []), ...(bookRules ?? [])].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0), + ); + if (!merged.length) return ctx.content; + + const processed = merged + .filter((r) => r.enabled && r.pattern.trim()) + .map((r) => ({ + ...r, + normalizedPattern: normalizePattern(r.pattern, r.isRegex, r.caseSensitive !== false), + })); + + if (!processed.length) return ctx.content; + + const parser = new DOMParser(); + const doc = parser.parseFromString(ctx.content, 'text/html'); + const textNodes = getTextNodes(doc); + + const byScope = { + selection: processed.filter((r) => r.scope === 'selection'), + book: processed.filter((r) => r.scope === 'book'), + library: processed.filter((r) => r.scope === 'library'), + }; + + const ordered = [...byScope.selection, ...byScope.book, ...byScope.library]; + + for (const rule of ordered) { + if (rule.scope === 'selection') { + const ruleBase = rule.sectionHref?.split('#')[0]; + const ctxBase = ctx.sectionHref?.split('#')[0]; + if (ctxBase !== ruleBase) continue; + } + if (rule.scope === 'selection') { + applyReplacementSingle(doc, rule); + } else { + applyReplacementMulti(textNodes, rule); + } + } + + return new XMLSerializer().serializeToString(doc); + }, +}; diff --git a/apps/readest-app/src/services/transformers/replacement.ts b/apps/readest-app/src/services/transformers/replacement.ts deleted file mode 100644 index 6312e9af..00000000 --- a/apps/readest-app/src/services/transformers/replacement.ts +++ /dev/null @@ -1,704 +0,0 @@ -import type { Transformer } from './types'; -import { ReplacementRule, ViewSettings } from '@/types/book'; -import { SystemSettings } from '@/types/settings'; -import { EnvConfigType } from '@/services/environment'; -import { useReaderStore } from '@/store/readerStore'; -import { useSettingsStore } from '@/store/settingsStore'; -import { useBookDataStore } from '@/store/bookDataStore'; -import { uniqueId } from '@/utils/misc'; - -// Whole-word enforcement for ALL rules (literal OR regex) -// Case-sensitive, with Unicode-aware boundaries for non-ASCII patterns -interface NormalizedPattern { - source: string; - flags: string; -} - -// Check if a character is a Unicode word character (letter, number, or underscore) -function isUnicodeWordChar(char: string): boolean { - if (!char) return false; - return /[\p{L}\p{N}_]/u.test(char); -} - -function normalizePattern( - pattern: string, - isRegex: boolean, - caseSensitive = true, -): NormalizedPattern { - const hasUnicode = /[^\x00-\x7F]/.test(pattern); - - let flags = ''; - if (hasUnicode) flags += 'u'; - flags += 'g'; - if (!caseSensitive) flags += 'i'; - - if (isRegex) { - // Do NOT escape regex patterns; just add boundaries if missing - if (pattern.includes('\\b')) { - return { source: pattern, flags }; - } - // For regex patterns, use \b for ASCII (works well) - // For Unicode, we'll rely on manual boundary checking in the matching functions - const source = hasUnicode - ? pattern // Don't add boundaries for Unicode regex - will check manually - : `\\b${pattern}\\b`; - return { source, flags }; - } - - // Escape literals - const escaped = pattern.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'); - - // Check if pattern has punctuation at start or end - const startsWithPunctuation = /^[^\w\s]/.test(pattern); - const endsWithPunctuation = /[^\w\s]$/.test(pattern); - const hasBoundaryPunctuation = startsWithPunctuation || endsWithPunctuation; - - // For ASCII patterns with boundary punctuation, add boundaries only around the word part - // For patterns without boundary punctuation, add boundaries around the whole pattern - // For Unicode patterns, we'll check boundaries manually - let source: string; - if (hasUnicode) { - // Don't add boundaries for Unicode - will check manually - source = escaped; - } else if (hasBoundaryPunctuation) { - // For patterns like "scholar;" or "'tis", find the word part and add boundaries only around it - const wordMatch = pattern.match(/[\w]+/); - if (!wordMatch || !wordMatch[0]) { - // No word characters found, just use escaped pattern without boundaries - source = escaped; - } else { - const wordPart = wordMatch[0]; - const wordStart = pattern.indexOf(wordPart); - const wordEnd = wordStart + wordPart.length; - - // Escape the word part separately - const wordEscaped = wordPart.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'); - - // Build the pattern: [before punctuation][word boundary][word][word boundary][after punctuation] - const beforeWord = escaped.substring(0, wordStart); - const afterWord = escaped.substring(wordEnd); - source = `${beforeWord}\\b${wordEscaped}\\b${afterWord}`; - } - } else { - // No boundary punctuation - add boundaries around the whole pattern - source = `\\b${escaped}\\b`; - } - - return { source, flags }; -} - -// Apply multi-replacement to text nodes -function applyRuleToTextNodesMulti( - textNodes: Text[], - rule: ReplacementRule & { normalizedPattern: NormalizedPattern }, -): void { - let regex: RegExp; - try { - regex = new RegExp(rule.normalizedPattern.source, rule.normalizedPattern.flags || 'g'); - } catch (_e) { - return; // Invalid regex - } - - for (const textNode of textNodes) { - if (!textNode.textContent) continue; - - let text = textNode.textContent; - const matches: { index: number; length: number }[] = []; - - let m; - while ((m = regex.exec(text)) !== null) { - const start = m.index; - - // For literal (non-regex) rules, ensure exact match - if (!rule.isRegex) { - const match = m[0]; - const pattern = rule.pattern; - const isCaseSensitive = rule.caseSensitive !== false; - const isMatch = isCaseSensitive - ? match === pattern - : match.toLowerCase() === pattern.toLowerCase(); - if (!isMatch) continue; - } - - // Manual whole-word boundary check for Unicode patterns - const hasUnicode = /[^\x00-\x7F]/.test(rule.pattern); - if (hasUnicode) { - const charBefore = text[start - 1] ?? ''; - const charAfter = text[start + m[0].length] ?? ''; - if (isUnicodeWordChar(charBefore) || isUnicodeWordChar(charAfter)) { - continue; - } - } - - matches.push({ index: start, length: m[0].length }); - } - - // Apply replacements in reverse order to maintain indices - for (let i = matches.length - 1; i >= 0; i--) { - const match = matches[i]; - if (!match) continue; - const { index, length } = match; - const end = index + length; - - text = text.slice(0, index) + rule.replacement + text.slice(end); - } - - // Update the text node with replaced content - if (matches.length > 0) { - textNode.textContent = text; - } - } -} - -// Apply single-instance replacement to text nodes -function applyRuleToTextNodesSingle( - textNodes: Text[], - rule: ReplacementRule & { normalizedPattern: NormalizedPattern }, -): void { - let regex: RegExp; - try { - regex = new RegExp(rule.normalizedPattern.source, rule.normalizedPattern.flags || 'g'); - } catch (_e) { - return; - } - - // Collect all matches across all text nodes - const allMatches: { nodeIndex: number; matchIndex: number; length: number }[] = []; - - for (let nodeIdx = 0; nodeIdx < textNodes.length; nodeIdx++) { - const textNode = textNodes[nodeIdx]; - if (!textNode || !textNode.textContent) continue; - - const text = textNode.textContent; - let m; - while ((m = regex.exec(text)) !== null) { - const start = m.index; - - // Case sensitive check for single-instance - if (!rule.isRegex && m[0] !== rule.pattern) continue; - - // Unicode whole-word check - const hasUnicode = /[^\x00-\x7F]/.test(rule.pattern); - if (hasUnicode) { - const charBefore = text[start - 1] ?? ''; - const charAfter = text[start + m[0].length] ?? ''; - if (isUnicodeWordChar(charBefore) || isUnicodeWordChar(charAfter)) { - continue; - } - } - - allMatches.push({ nodeIndex: nodeIdx, matchIndex: start, length: m[0].length }); - } - } - - // Apply replacement to the target occurrence - const targetIndex = rule.occurrenceIndex ?? 0; - const target = allMatches[targetIndex]; - if (!target) return; - - const textNode = textNodes[target.nodeIndex]; - if (!textNode || !textNode.textContent) return; - - const text = textNode.textContent; - const newText = - text.slice(0, target.matchIndex) + - rule.replacement + - text.slice(target.matchIndex + target.length); - textNode.textContent = newText; -} - -// DOM-Based Transformer function -export const replacementTransformer: Transformer = { - name: 'replacement', - - transform: async (ctx) => { - const globalRaw = useSettingsStore.getState().settings?.globalViewSettings?.replacementRules; - const bookRaw = ctx.viewSettings.replacementRules; - - const merged = mergeReplacementRules(globalRaw, bookRaw); - if (!merged || merged.length === 0) return ctx.content; - - const processed = merged - .filter((r) => r.enabled && r.pattern.trim().length > 0) - .map((r) => ({ - ...r, - normalizedPattern: normalizePattern(r.pattern, r.isRegex, r.caseSensitive !== false), - })); - - if (processed.length === 0) return ctx.content; - - // Parse HTML into DOM - const parser = new DOMParser(); - const doc = parser.parseFromString(ctx.content, 'text/html'); - - // Get all text nodes using TreeWalker - const walker = document.createTreeWalker( - doc.body || doc.documentElement, - NodeFilter.SHOW_TEXT, - { - acceptNode: (node) => { - const parent = node.parentElement; - // Skip script and style tags - if (parent && (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE')) { - return NodeFilter.FILTER_REJECT; - } - // Only accept nodes with actual text content - return node.textContent?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; - }, - }, - ); - - // Collect all text nodes - const textNodes: Text[] = []; - let currentNode: Node | null; - while ((currentNode = walker.nextNode())) { - textNodes.push(currentNode as Text); - } - - // Separate rules by type - const singleRules = processed.filter((r) => r.singleInstance); - const bookRules = processed.filter((r) => !r.singleInstance && !r.global); - const globalRules = processed.filter((r) => !r.singleInstance && r.global); - - const ordered = [...singleRules, ...bookRules, ...globalRules]; - - // Apply replacements to text nodes - for (const rule of ordered) { - // Check section match for single-instance rules - if (rule.singleInstance && rule.sectionHref) { - const ruleBase = rule.sectionHref.split('#')[0]; - const ctxBase = ctx.sectionHref?.split('#')[0]; - if (ctxBase !== ruleBase) continue; - } - - if (rule.singleInstance) { - applyRuleToTextNodesSingle(textNodes, rule); - } else { - applyRuleToTextNodesMulti(textNodes, rule); - } - } - - // Serialize back to HTML - const serializer = new XMLSerializer(); - return serializer.serializeToString(doc); - }, -}; - -// Rule management -export type ReplacementRuleScope = 'single' | 'book' | 'global'; - -export interface CreateReplacementRuleOptions { - pattern: string; - replacement: string; - isRegex?: boolean; - enabled?: boolean; - caseSensitive?: boolean; - order?: number; - singleInstance?: boolean; - sectionHref?: string; - occurrenceIndex?: number; - wholeWord?: boolean; - global?: boolean; -} - -export function createReplacementRule(opts: CreateReplacementRuleOptions): ReplacementRule { - return { - id: uniqueId(), - pattern: opts.pattern, - replacement: opts.replacement, - isRegex: opts.isRegex ?? false, - enabled: opts.enabled ?? true, - caseSensitive: opts.caseSensitive ?? true, - order: opts.order ?? 1000, - singleInstance: opts.singleInstance ?? false, - wholeWord: opts.wholeWord ?? true, - sectionHref: opts.sectionHref, - occurrenceIndex: opts.occurrenceIndex, - global: opts.global ?? false, - }; -} - -export function mergeReplacementRules( - globalRules: ReplacementRule[] | undefined, - bookRules: ReplacementRule[] | undefined, -): ReplacementRule[] { - const map = new Map(); - - for (const g of globalRules ?? []) map.set(g.id, g); - for (const b of bookRules ?? []) map.set(b.id, b); - - return [...map.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); -} - -/** - * Gets all active replacement rules for a book (merged global + book rules) - */ -export function getMergedReplacementRules(bookKey: string): ReplacementRule[] { - const { settings } = useSettingsStore.getState(); - const { getViewSettings } = useReaderStore.getState(); - const viewSettings = getViewSettings(bookKey); - - const globalRules = settings.globalViewSettings.replacementRules; - const bookRules = viewSettings?.replacementRules; - - return mergeReplacementRules(globalRules, bookRules); -} - -/** - * Adds a replacement rule at the specified scope - */ -export async function addReplacementRule( - envConfig: EnvConfigType, - bookKey: string, - options: CreateReplacementRuleOptions, - scope: ReplacementRuleScope, -): Promise { - const rule = createReplacementRule(options); - - switch (scope) { - case 'single': - // Single-instance replacement: persisted in book config for refresh persistence - await addReplacementRuleToBook(envConfig, bookKey, rule, true); - break; - case 'book': - // Apply to entire book (persisted in book config) - await addReplacementRuleToBook(envConfig, bookKey, rule, true); - break; - case 'global': - // Apply globally to all books - await addReplacementRuleToGlobal(envConfig, rule); - break; - } - - return rule; -} - -/** - * Adds a rule to a specific book's viewSettings - */ -async function addReplacementRuleToBook( - envConfig: EnvConfigType, - bookKey: string, - rule: ReplacementRule, - persist: boolean, -): Promise { - const { getViewSettings, setViewSettings } = useReaderStore.getState(); - const { getConfig, saveConfig } = useBookDataStore.getState(); - const { settings } = useSettingsStore.getState(); - - const viewSettings = getViewSettings(bookKey); - if (!viewSettings) { - throw new Error(`No viewSettings found for book: ${bookKey}`); - } - - // Get existing book rules - const existingRules = viewSettings.replacementRules || []; - - if (rule.singleInstance) { - // Single-instance rules: ALWAYS create new rule (each has unique ID) - // Don't try to merge - after DOM modifications, occurrence indices shift - // and we can't reliably detect duplicates - existingRules.push(rule); - } else { - // Non-single-instance: check if same pattern exists to avoid duplicates - const existingRule = existingRules.find( - (r) => r.pattern === rule.pattern && r.isRegex === rule.isRegex && !r.singleInstance, - ); - - if (existingRule) { - // Update existing rule - existingRule.replacement = rule.replacement; - existingRule.enabled = rule.enabled; - existingRule.order = rule.order; - } else { - // Add new rule - existingRules.push(rule); - } - } - - // Update viewSettings - const updatedViewSettings: ViewSettings = { - ...viewSettings, - replacementRules: existingRules, - }; - - setViewSettings(bookKey, updatedViewSettings); - - // Persist if requested - if (persist) { - const config = getConfig(bookKey); - if (config) { - const updatedConfig = { - ...config, - viewSettings: updatedViewSettings, - updatedAt: Date.now(), - }; - await saveConfig(envConfig, bookKey, updatedConfig, settings); - } - } -} - -/** - * Adds a rule to global viewSettings - */ -async function addReplacementRuleToGlobal( - envConfig: EnvConfigType, - rule: ReplacementRule, -): Promise { - const { settings, setSettings, saveSettings } = useSettingsStore.getState(); - - const globalRules = settings.globalViewSettings.replacementRules || []; - - // Check if rule with same pattern already exists - const existingRule = globalRules.find( - (r) => r.pattern === rule.pattern && r.isRegex === rule.isRegex, - ); - - if (existingRule) { - // Update existing rule - existingRule.replacement = rule.replacement; - existingRule.enabled = rule.enabled; - existingRule.order = rule.order; - } else { - // Add new rule - globalRules.push(rule); - } - - // Update global settings - const updatedSettings: SystemSettings = { - ...settings, - globalViewSettings: { - ...settings.globalViewSettings, - replacementRules: globalRules, - }, - }; - - setSettings(updatedSettings); - await saveSettings(envConfig, updatedSettings); -} - -/** - * Removes a replacement rule by ID - */ -export async function removeReplacementRule( - envConfig: EnvConfigType, - bookKey: string, - ruleId: string, - scope: ReplacementRuleScope, -): Promise { - switch (scope) { - case 'single': - // Single-instance rules are persisted in book config - await removeReplacementRuleFromBook(envConfig, bookKey, ruleId, true); - break; - case 'book': - // Book-wide rules are persisted in book config - await removeReplacementRuleFromBook(envConfig, bookKey, ruleId, true); - break; - case 'global': - await removeReplacementRuleFromGlobal(envConfig, ruleId); - break; - } -} - -/** - * Removes a rule from a book's viewSettings - */ -async function removeReplacementRuleFromBook( - envConfig: EnvConfigType, - bookKey: string, - ruleId: string, - persist: boolean, -): Promise { - const { getViewSettings, setViewSettings } = useReaderStore.getState(); - const { getConfig, saveConfig } = useBookDataStore.getState(); - const { settings } = useSettingsStore.getState(); - - const viewSettings = getViewSettings(bookKey); - if (!viewSettings) { - throw new Error(`No viewSettings found for book: ${bookKey}`); - } - - const existingRules = viewSettings.replacementRules || []; - const filteredRules = existingRules.filter((r) => r.id !== ruleId); - - const updatedViewSettings: ViewSettings = { - ...viewSettings, - replacementRules: filteredRules, - }; - - setViewSettings(bookKey, updatedViewSettings); - - if (persist) { - const config = getConfig(bookKey); - if (config) { - const updatedConfig = { - ...config, - viewSettings: updatedViewSettings, - updatedAt: Date.now(), - }; - await saveConfig(envConfig, bookKey, updatedConfig, settings); - } - } -} - -/** - * Removes a rule from global viewSettings - */ -async function removeReplacementRuleFromGlobal( - envConfig: EnvConfigType, - ruleId: string, -): Promise { - const { settings, setSettings, saveSettings } = useSettingsStore.getState(); - - const globalRules = settings.globalViewSettings.replacementRules || []; - const filteredRules = globalRules.filter((r) => r.id !== ruleId); - - const updatedSettings: SystemSettings = { - ...settings, - globalViewSettings: { - ...settings.globalViewSettings, - replacementRules: filteredRules, - }, - }; - - setSettings(updatedSettings); - await saveSettings(envConfig, updatedSettings); -} - -/** - * Updates an existing replacement rule - */ -export async function updateReplacementRule( - envConfig: EnvConfigType, - bookKey: string, - ruleId: string, - updates: Partial>, - scope: ReplacementRuleScope, -): Promise { - switch (scope) { - case 'single': - // Single-instance rules are persisted in book config - await updateReplacementRuleInBook(envConfig, bookKey, ruleId, updates, true); - break; - case 'book': - // Book-wide rules are persisted in book config - await updateReplacementRuleInBook(envConfig, bookKey, ruleId, updates, true); - break; - case 'global': - await updateReplacementRuleInGlobal(envConfig, ruleId, updates); - break; - } -} - -/** - * Updates a rule in a book's viewSettings - */ -async function updateReplacementRuleInBook( - envConfig: EnvConfigType, - bookKey: string, - ruleId: string, - updates: Partial>, - persist: boolean, -): Promise { - const { getViewSettings, setViewSettings } = useReaderStore.getState(); - const { getConfig, saveConfig } = useBookDataStore.getState(); - const { settings } = useSettingsStore.getState(); - - const viewSettings = getViewSettings(bookKey); - if (!viewSettings) { - throw new Error(`No viewSettings found for book: ${bookKey}`); - } - - const existingRules = viewSettings.replacementRules || []; - const updatedRules = existingRules.map((r) => (r.id === ruleId ? { ...r, ...updates } : r)); - - const updatedViewSettings: ViewSettings = { - ...viewSettings, - replacementRules: updatedRules, - }; - - setViewSettings(bookKey, updatedViewSettings); - - if (persist) { - const config = getConfig(bookKey); - if (config) { - const updatedConfig = { - ...config, - viewSettings: updatedViewSettings, - updatedAt: Date.now(), - }; - await saveConfig(envConfig, bookKey, updatedConfig, settings); - } - } -} - -/** - * Updates a rule in global viewSettings - */ -async function updateReplacementRuleInGlobal( - envConfig: EnvConfigType, - ruleId: string, - updates: Partial>, -): Promise { - const { settings, setSettings, saveSettings } = useSettingsStore.getState(); - - const globalRules = settings.globalViewSettings.replacementRules || []; - const updatedRules = globalRules.map((r) => (r.id === ruleId ? { ...r, ...updates } : r)); - - const updatedSettings: SystemSettings = { - ...settings, - globalViewSettings: { - ...settings.globalViewSettings, - replacementRules: updatedRules, - }, - }; - - setSettings(updatedSettings); - await saveSettings(envConfig, updatedSettings); -} - -/** - * Toggles a rule's enabled state - */ -export async function toggleReplacementRule( - envConfig: EnvConfigType, - bookKey: string, - ruleId: string, - scope: ReplacementRuleScope, -): Promise { - const mergedRules = getMergedReplacementRules(bookKey); - const rule = mergedRules.find((r) => r.id === ruleId); - - if (!rule) { - throw new Error(`Rule not found: ${ruleId}`); - } - - await updateReplacementRule(envConfig, bookKey, ruleId, { enabled: !rule.enabled }, scope); -} - -/** - * Validates a replacement rule pattern - */ -export function validateReplacementRulePattern( - pattern: string, - isRegex: boolean, -): { valid: boolean; error?: string } { - if (!pattern || pattern.trim().length === 0) { - return { valid: false, error: 'Pattern cannot be empty' }; - } - - if (isRegex) { - try { - new RegExp(pattern); - return { valid: true }; - } catch (error) { - return { - valid: false, - error: error instanceof Error ? error.message : 'Invalid regex pattern', - }; - } - } - - return { valid: true }; -} diff --git a/apps/readest-app/src/services/transformers/types.ts b/apps/readest-app/src/services/transformers/types.ts index 1d89239b..94bde888 100644 --- a/apps/readest-app/src/services/transformers/types.ts +++ b/apps/readest-app/src/services/transformers/types.ts @@ -8,9 +8,9 @@ export type TransformContext = { width?: number; height?: number; content: string; + sectionHref?: string; transformers: string[]; reversePunctuationTransform?: boolean; - sectionHref?: string; // Section href for single-instance replacement tracking }; export type Transformer = { diff --git a/apps/readest-app/src/store/proofreadStore.ts b/apps/readest-app/src/store/proofreadStore.ts new file mode 100644 index 00000000..67bdcb05 --- /dev/null +++ b/apps/readest-app/src/store/proofreadStore.ts @@ -0,0 +1,325 @@ +import { create } from 'zustand'; +import { ProofreadRule, ProofreadScope, ViewSettings } from '@/types/book'; +import { SystemSettings } from '@/types/settings'; +import { EnvConfigType } from '@/services/environment'; +import { useReaderStore } from '@/store/readerStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { uniqueId } from '@/utils/misc'; + +export interface CreateProofreadRuleOptions { + scope: ProofreadScope; + pattern: string; + replacement: string; + cfi?: string; + sectionHref?: string; + isRegex?: boolean; + enabled?: boolean; + caseSensitive?: boolean; + order?: number; + wholeWord?: boolean; +} + +interface ProofreadStoreState { + getBookRules: (bookKey: string) => ProofreadRule[]; + getGlobalRules: () => ProofreadRule[]; + getMergedRules: (bookKey: string) => ProofreadRule[]; + + addRule: ( + envConfig: EnvConfigType, + bookKey: string, + options: CreateProofreadRuleOptions, + ) => Promise; + updateRule: ( + envConfig: EnvConfigType, + bookKey: string, + ruleId: string, + updates: Partial>, + ) => Promise; + removeRule: ( + envConfig: EnvConfigType, + bookKey: string, + ruleId: string, + scope: ProofreadScope, + ) => Promise; + toggleRule: (envConfig: EnvConfigType, bookKey: string, ruleId: string) => Promise; +} + +function createProofreadRule(opts: CreateProofreadRuleOptions): ProofreadRule { + return { + id: uniqueId(), + scope: opts.scope, + pattern: opts.pattern, + replacement: opts.replacement, + cfi: opts.cfi, + sectionHref: opts.sectionHref, + isRegex: opts.isRegex ?? false, + enabled: opts.enabled ?? true, + caseSensitive: opts.caseSensitive ?? true, + order: opts.order ?? 1000, + wholeWord: opts.wholeWord ?? true, + }; +} + +function mergeRules( + globalRules: ProofreadRule[] | undefined, + bookRules: ProofreadRule[] | undefined, +): ProofreadRule[] { + const map = new Map(); + + (globalRules ?? []).forEach((r) => map.set(r.id, r)); + (bookRules ?? []).forEach((r) => map.set(r.id, r)); + + return [...map.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} + +export const useProofreadStore = create(() => ({ + getBookRules: (bookKey: string) => { + const { getViewSettings } = useReaderStore.getState(); + const viewSettings = getViewSettings(bookKey); + return viewSettings?.proofreadRules || []; + }, + + getGlobalRules: () => { + const { settings } = useSettingsStore.getState(); + return settings.globalViewSettings.proofreadRules || []; + }, + + getMergedRules: (bookKey: string) => { + const { settings } = useSettingsStore.getState(); + const { getViewSettings } = useReaderStore.getState(); + const viewSettings = getViewSettings(bookKey); + + return mergeRules(settings.globalViewSettings.proofreadRules, viewSettings?.proofreadRules); + }, + + addRule: async (envConfig, bookKey, options) => { + const rule = createProofreadRule(options); + + if (options.scope === 'library') { + await addGlobalRule(envConfig, rule); + } else { + await addBookRule(envConfig, bookKey, rule, options.scope); + } + + return rule; + }, + + updateRule: async (envConfig, bookKey, ruleId, updates) => { + if (updates.scope === 'library') { + await updateGlobalRule(envConfig, ruleId, updates); + } else { + await updateBookRule(envConfig, bookKey, ruleId, updates); + } + }, + + removeRule: async (envConfig, bookKey, ruleId, scope) => { + if (scope === 'library') { + await removeGlobalRule(envConfig, ruleId); + } else { + await removeBookRule(envConfig, bookKey, ruleId); + } + }, + + toggleRule: async (envConfig, bookKey, ruleId) => { + const { getMergedRules } = useProofreadStore.getState(); + const mergedRules = getMergedRules(bookKey); + const rule = mergedRules.find((r) => r.id === ruleId); + + if (!rule) { + throw new Error(`Rule not found: ${ruleId}`); + } + + const { updateRule } = useProofreadStore.getState(); + await updateRule(envConfig, bookKey, ruleId, { enabled: !rule.enabled }); + }, +})); + +async function addBookRule( + envConfig: EnvConfigType, + bookKey: string, + rule: ProofreadRule, + scope: ProofreadScope, +): Promise { + const { getViewSettings } = useReaderStore.getState(); + + const viewSettings = getViewSettings(bookKey); + if (!viewSettings) return; + + const existingRules = viewSettings.proofreadRules || []; + + if (scope === 'selection') { + // Always add new single-instance rules (each has unique ID) + existingRules.push(rule); + } else { + // Check for duplicates in book scope + const existing = existingRules.find( + (r) => r.pattern === rule.pattern && r.isRegex === rule.isRegex && r.scope !== 'selection', + ); + + if (existing) { + Object.assign(existing, { + replacement: rule.replacement, + enabled: rule.enabled, + order: rule.order, + }); + } else { + existingRules.push(rule); + } + } + + await updateBookViewSettings(envConfig, bookKey, existingRules); +} + +async function updateBookRule( + envConfig: EnvConfigType, + bookKey: string, + ruleId: string, + updates: Partial>, +): Promise { + const { getViewSettings } = useReaderStore.getState(); + + const viewSettings = getViewSettings(bookKey); + if (!viewSettings) { + throw new Error(`No viewSettings found for book: ${bookKey}`); + } + + const existingRules = viewSettings.proofreadRules || []; + const updatedRules = existingRules.map((r) => (r.id === ruleId ? { ...r, ...updates } : r)); + + await updateBookViewSettings(envConfig, bookKey, updatedRules); +} + +async function removeBookRule( + envConfig: EnvConfigType, + bookKey: string, + ruleId: string, +): Promise { + const { getViewSettings } = useReaderStore.getState(); + + const viewSettings = getViewSettings(bookKey); + if (!viewSettings) { + throw new Error(`No viewSettings found for book: ${bookKey}`); + } + + const existingRules = viewSettings.proofreadRules || []; + const filteredRules = existingRules.filter((r) => r.id !== ruleId); + + await updateBookViewSettings(envConfig, bookKey, filteredRules); +} + +async function updateBookViewSettings( + envConfig: EnvConfigType, + bookKey: string, + rules: ProofreadRule[], +): Promise { + const { getViewSettings, setViewSettings } = useReaderStore.getState(); + const { getConfig, saveConfig } = useBookDataStore.getState(); + const { settings } = useSettingsStore.getState(); + + const viewSettings = getViewSettings(bookKey); + if (!viewSettings) { + throw new Error(`No viewSettings found for book: ${bookKey}`); + } + + const updatedViewSettings: ViewSettings = { + ...viewSettings, + proofreadRules: rules, + }; + + setViewSettings(bookKey, updatedViewSettings); + + const config = getConfig(bookKey); + if (config) { + await saveConfig( + envConfig, + bookKey, + { ...config, viewSettings: updatedViewSettings, updatedAt: Date.now() }, + settings, + ); + } +} + +async function addGlobalRule(envConfig: EnvConfigType, rule: ProofreadRule): Promise { + const { settings } = useSettingsStore.getState(); + if (!settings || !settings.globalViewSettings) return; + + const globalRules = settings.globalViewSettings.proofreadRules || []; + + const existing = globalRules.find( + (r) => r.pattern === rule.pattern && r.isRegex === rule.isRegex, + ); + + if (existing) { + Object.assign(existing, { + replacement: rule.replacement, + enabled: rule.enabled, + order: rule.order, + }); + await updateGlobalSettings(envConfig, globalRules); + } else { + await updateGlobalSettings(envConfig, [...globalRules, rule]); + } +} + +async function updateGlobalRule( + envConfig: EnvConfigType, + ruleId: string, + updates: Partial>, +): Promise { + const { settings } = useSettingsStore.getState(); + const globalRules = settings.globalViewSettings.proofreadRules || []; + + const updatedRules = globalRules.map((r) => (r.id === ruleId ? { ...r, ...updates } : r)); + + await updateGlobalSettings(envConfig, updatedRules); +} + +async function removeGlobalRule(envConfig: EnvConfigType, ruleId: string): Promise { + const { settings } = useSettingsStore.getState(); + const globalRules = settings.globalViewSettings.proofreadRules || []; + + const filteredRules = globalRules.filter((r) => r.id !== ruleId); + await updateGlobalSettings(envConfig, filteredRules); +} + +async function updateGlobalSettings( + envConfig: EnvConfigType, + rules: ProofreadRule[], +): Promise { + const { settings, setSettings, saveSettings } = useSettingsStore.getState(); + + const updatedSettings: SystemSettings = { + ...settings, + globalViewSettings: { + ...settings.globalViewSettings, + proofreadRules: rules, + }, + }; + + setSettings(updatedSettings); + await saveSettings(envConfig, updatedSettings); +} + +export function validateReplacementRulePattern( + pattern: string, + isRegex: boolean, +): { valid: boolean; error?: string } { + if (!pattern?.trim()) { + return { valid: false, error: 'Pattern cannot be empty' }; + } + + if (isRegex) { + try { + new RegExp(pattern); + return { valid: true }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : 'Invalid regex pattern', + }; + } + } + + return { valid: true }; +} diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 7f34250c..77e10246 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -210,24 +210,24 @@ export interface ScreenConfig { screenOrientation: 'auto' | 'portrait' | 'landscape'; } +export type ProofreadScope = 'selection' | 'book' | 'library'; -export interface ReplacementRule { +export interface ProofreadRule { id: string; + scope: ProofreadScope; pattern: string; replacement: string; + cfi?: string; + sectionHref?: string; enabled: boolean; isRegex: boolean; order: number; // Lower numbers apply first - singleInstance?: boolean; // If true, only replace the specific occurrence - sectionHref?: string; // Section where the single-instance replacement applies - occurrenceIndex?: number; // Which occurrence in the section (0-based) wholeWord?: boolean; // Match whole words only (uses \b word boundaries) caseSensitive?: boolean; // Case-sensitive matching (default true) - global?: boolean; // Marks global-scope rules explicitly } -export interface ReplacementRulesConfig { - replacementRules?: ReplacementRule[]; +export interface ProofreadRulesConfig { + proofreadRules?: ProofreadRule[]; } export interface ViewSettings @@ -239,7 +239,7 @@ export interface ViewSettings TTSConfig, TranslatorConfig, ScreenConfig, - ReplacementRulesConfig {} + ProofreadRulesConfig {} export interface BookProgress { location: string; diff --git a/apps/readest-app/src/utils/sel.ts b/apps/readest-app/src/utils/sel.ts index f8cc44ba..205d9d8e 100644 --- a/apps/readest-app/src/utils/sel.ts +++ b/apps/readest-app/src/utils/sel.ts @@ -27,6 +27,7 @@ export interface TextSelection { text: string; range: Range; index: number; + cfi?: string; href?: string; annotated?: boolean; } diff --git a/apps/readest-app/src/utils/word.ts b/apps/readest-app/src/utils/word.ts new file mode 100644 index 00000000..6a2bfc2e --- /dev/null +++ b/apps/readest-app/src/utils/word.ts @@ -0,0 +1,86 @@ +// Helper to check if selected text is a whole word (has word boundaries on both sides) +// Note: Word boundary checks only apply to space-separated languages (English, etc.) +// CJK languages (Chinese, Japanese, Korean) don't require boundary checks +export const isWholeWord = (range: Range, selectedText: string): boolean => { + try { + const trimmed = selectedText.trim(); + if (!trimmed) return false; + + // Unicode-aware patterns + const wordCharPattern = /[\p{L}\p{N}\p{M}_]/u; + const cjkPattern = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; + + // Must contain at least one word character + if (!wordCharPattern.test(trimmed)) return false; + + // CJK text doesn't need boundary checks (no spaces between words) + if (cjkPattern.test(trimmed)) return true; + + // Phrases (with spaces/punctuation) are always valid + if (/[\s\p{P}\p{S}]/u.test(trimmed)) return true; + + // For single words in space-separated languages, check boundaries + const { charBefore, charAfter } = getBoundaryChars(range); + const isWordChar = (char: string) => wordCharPattern.test(char); + + return (!charBefore || !isWordChar(charBefore)) && (!charAfter || !isWordChar(charAfter)); + } catch (e) { + console.warn('Failed to check whole word:', e); + return /[\p{L}\p{N}\p{M}_]/u.test(selectedText); + } +}; + +// Helper to get characters before and after the selection +const getBoundaryChars = (range: Range): { charBefore: string; charAfter: string } => { + let charBefore = ''; + let charAfter = ''; + + try { + // Get character before + const startNode = range.startContainer; + if (startNode.nodeType === Node.TEXT_NODE) { + const textNode = startNode as Text; + if (range.startOffset > 0) { + charBefore = textNode.textContent?.charAt(range.startOffset - 1) || ''; + } else { + // Check previous sibling + let prev = startNode.previousSibling; + while (prev && prev.nodeType !== Node.TEXT_NODE) { + prev = prev.previousSibling; + } + if (prev?.nodeType === Node.TEXT_NODE) { + const prevText = (prev as Text).textContent || ''; + charBefore = prevText.charAt(prevText.length - 1); + } + } + } + + // Get character after + const endNode = range.endContainer; + if (endNode.nodeType === Node.TEXT_NODE) { + const textNode = endNode as Text; + const textContent = textNode.textContent || ''; + if (range.endOffset < textContent.length) { + charAfter = textContent.charAt(range.endOffset); + } else { + // Check next sibling + let next = textNode.nextSibling; + while (next && next.nodeType !== Node.TEXT_NODE) { + next = next.nextSibling; + } + if (next?.nodeType === Node.TEXT_NODE) { + charAfter = ((next as Text).textContent || '').charAt(0); + } + } + } + } catch (e) { + console.warn('[getBoundaryChars] Error:', e); + } + + return { charBefore, charAfter }; +}; + +export const getWordCount = (text: string): number => { + if (!text) return 0; + return text.trim().split(/\s+/).filter(Boolean).length; +}; diff --git a/apps/readest-app/src/utils/wordLimit.ts b/apps/readest-app/src/utils/wordLimit.ts deleted file mode 100644 index ab216e22..00000000 --- a/apps/readest-app/src/utils/wordLimit.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const MAX_REPLACEMENT_WORDS = 30; - -export const getWordCount = (text: string): number => { - if (!text) return 0; - return text.trim().split(/\s+/).filter(Boolean).length; -}; - -export const isWordLimitExceeded = (text: string): boolean => { - return getWordCount(text) > MAX_REPLACEMENT_WORDS; -}; diff --git a/packages/foliate-js b/packages/foliate-js index d15091c5..43f30812 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit d15091c5239d6a2720a8a80d5036d725f6b9a9d3 +Subproject commit 43f30812d19f49c9539cf9ca4fb558f90422c624