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(
- ,
- );
-
- 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,
'',
);
- 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 (
+
+
+ {_('Selected text:')}
+
+
+
+
+ {_('Replace with:')}
+ onEditChange('replacement', e.target.value)}
+ />
+
+
+
+
+ {_('Save')}
+
+
+ {_('Cancel')}
+
+
+
+ );
+ }
+
+ 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 ? (
+
+ ) : (
+
+ {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' ? (
-
-
-
- {_('Selected phrase:')}
-
-
-
-
-
-
- {_('Replace with:')}
-
-
- setEditing({ ...editing, replacement: e.target.value })
- }
- />
-
-
-
-
- {_('Save')}
-
-
- {_('Cancel')}
-
-
-
- ) : (
-
-
-
{r.pattern}
-
-
- {_('Replace with:')}
-
- {r.replacement}
-
-
- {_('Scope:')} Single Instance
- | {_('Case sensitive:')}
-
- {r.caseSensitive !== false ? _('Yes') : _('No')}
-
-
-
-
- startEdit(r, 'single')}
- aria-label={_('Edit')}
- >
-
-
- deleteRule(r.id, 'single')}
- aria-label={_('Delete')}
- >
-
-
-
-
- )}
-
- ))}
-
- )}
-
-
{_('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 ? (
-
-
-
- {_('Selected phrase:')}
-
-
-
-
-
-
- {_('Replace with:')}
-
-
- setEditing({ ...editing, replacement: e.target.value })
- }
- />
-
-
-
-
- {_('Save')}
-
-
- {_('Cancel')}
-
-
-
- ) : (
-
-
-
{r.pattern}
-
-
- {_('Replace with:')}
-
- {r.replacement}
-
-
- {_('Scope:')}
-
- {getRuleScope(r) === 'book' ? _('Book') : _('Global')}
-
- | {_('Case sensitive:')}
-
- {r.caseSensitive !== false ? _('Yes') : _('No')}
-
-
-
-
- startEdit(r, getRuleScope(r))}
- aria-label={_('Edit')}
- >
-
-
- deleteRule(r.id, ruleScope)}
- aria-label={_('Delete')}
- >
-
-
-
-
- )}
-
- );
- })}
-
- )}
-
-
- )}
-
- );
-};
-
-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 (
void;
+ onDismiss: () => void;
+}
+
+const ProofreadPopup: React.FC = ({
+ bookKey,
+ selection,
+ position,
+ trianglePosition,
+ popupWidth,
+ popupHeight,
+ onConfirm,
+ onDismiss,
+}) => {
+ const _ = useTranslation();
+ const { envConfig } = useEnv();
+ const { getProgress, getView, recreateViewer } = useReaderStore();
+ const { addRule } = useProofreadStore();
+ const progress = getProgress(bookKey)!;
+
+ const [replacementText, setReplacementText] = useState('');
+ const [caseSensitive, setCaseSensitive] = useState(true);
+ const [scope, setScope] = useState('selection');
+
+ const inputRef = useRef(null);
+ useAutoFocus({ ref: inputRef });
+
+ const handleScopeChange = (event: React.ChangeEvent) => {
+ setScope(event.target.value as ProofreadScope);
+ };
+
+ const handleApply = async () => {
+ if (!selection) return;
+
+ const range = selection?.range;
+
+ if (range) {
+ 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;
+ }
+
+ if (scope === 'selection') {
+ range.deleteContents();
+ const textNode = document.createTextNode(replacementText);
+ range.insertNode(textNode);
+ }
+
+ const options = {
+ scope,
+ pattern: selection.text,
+ replacement: replacementText.trim(),
+ cfi: selection.cfi,
+ sectionHref: progress?.sectionHref,
+ isRegex: false,
+ enabled: true,
+ caseSensitive,
+ wholeWord: true,
+ };
+ onConfirm?.(options);
+
+ await addRule(envConfig, bookKey, options);
+
+ onDismiss();
+
+ if (scope !== 'selection') {
+ if (getView(bookKey)) {
+ recreateViewer(envConfig, bookKey);
+ }
+ }
+ }
+ };
+
+ const scopeOptions = [
+ { value: 'selection', label: _('Current selection') },
+ { value: 'book', label: _('All occurrences in this book') },
+ { value: 'library', label: _('All occurrences in your library') },
+ ];
+
+ return (
+
+
+
+
+ {_('Selected text:')}
+
+ "{selection?.text || ''}"
+
+
+
+
+
+ {_('Replace with:')}
+
+ setReplacementText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && replacementText.trim()) {
+ handleApply();
+ }
+ }}
+ placeholder={_('Enter text...')}
+ className='w-full flex-1 rounded-md bg-gray-600 p-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-0'
+ />
+
+ {_('Apply')}
+
+
+
+
+
+
+
+ {_('Case sensitive:')}
+
+ setCaseSensitive(e.target.checked)}
+ />
+
+
+
+
+ {_('Scope:')}
+
+
+
+
+
+
+ );
+};
+
+export default ProofreadPopup;
diff --git a/apps/readest-app/src/app/reader/components/annotator/ReplacementOptions.tsx b/apps/readest-app/src/app/reader/components/annotator/ReplacementOptions.tsx
deleted file mode 100644
index 5ba5a789..00000000
--- a/apps/readest-app/src/app/reader/components/annotator/ReplacementOptions.tsx
+++ /dev/null
@@ -1,284 +0,0 @@
-'use client';
-
-import clsx from 'clsx';
-import React, { useEffect, useRef, useState } from 'react';
-
-export interface ReplacementConfig {
- replacementText: string;
- caseSensitive: boolean;
- scope: 'once' | 'book' | 'library';
-}
-
-interface ReplacementOptionsProps {
- isVertical: boolean;
- style: React.CSSProperties;
- selectedText: string;
- onConfirm: (config: ReplacementConfig) => void;
- onClose: () => void;
-}
-
-const ReplacementOptions: React.FC = ({
- style,
- isVertical,
- selectedText,
- onConfirm,
- onClose,
-}) => {
- const menuRef = useRef(null);
- const inputRef = useRef(null);
-
- const [replacementText, setReplacementText] = useState('');
- const [caseSensitive, setCaseSensitive] = useState(true);
- const [selectedScope, setSelectedScope] = useState<'once' | 'book' | 'library' | null>(null);
- const [showConfirmation, setShowConfirmation] = useState(false);
- const [adjustedStyle, setAdjustedStyle] = useState(null);
- const [isPositioned, setIsPositioned] = useState(false);
- const hasAdjusted = useRef(false);
-
- // Adjust position to stay within viewport - only once on initial render
- useEffect(() => {
- // Only adjust once to prevent jumping when other UI elements appear
- if (menuRef.current && !hasAdjusted.current) {
- // Use requestAnimationFrame to ensure the element is rendered before measuring
- requestAnimationFrame(() => {
- if (menuRef.current) {
- const rect = menuRef.current.getBoundingClientRect();
- const viewportHeight = window.innerHeight;
- const viewportWidth = window.innerWidth;
- const padding = 10;
-
- const newStyle = { ...style };
-
- // Check if popup extends beyond bottom of viewport
- if (rect.bottom > viewportHeight - padding) {
- const currentTop = parseFloat(String(style.top)) || 0;
- // Move popup above the selection instead
- newStyle.top = `${Math.max(padding, currentTop - rect.height - 40)}px`;
- }
-
- // Check if popup extends beyond right of viewport
- if (rect.right > viewportWidth - padding) {
- newStyle.left = `${Math.max(padding, viewportWidth - rect.width - padding)}px`;
- }
-
- // Check if popup extends beyond left of viewport
- if (rect.left < padding) {
- newStyle.left = `${padding}px`;
- }
-
- setAdjustedStyle(newStyle);
- hasAdjusted.current = true;
- setIsPositioned(true);
- }
- });
- }
- }, [style]);
-
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
- onClose();
- }
- };
-
- document.addEventListener('mousedown', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, [onClose]);
-
- // Focus input on mount
- useEffect(() => {
- if (inputRef.current) {
- inputRef.current.focus();
- }
- }, []);
-
- const handleScopeClick = (scope: 'once' | 'book' | 'library') => {
- if (!replacementText.trim()) {
- // Show error if no replacement text
- return;
- }
- setSelectedScope(scope);
- setShowConfirmation(true);
- };
-
- const handleConfirm = () => {
- if (selectedScope && replacementText.trim()) {
- onConfirm({
- replacementText: replacementText.trim(),
- caseSensitive,
- scope: selectedScope,
- });
- }
- };
-
- const handleCancelConfirmation = () => {
- setShowConfirmation(false);
- setSelectedScope(null);
- };
-
- const handleCancel = () => {
- onClose();
- };
-
- const getScopeLabel = (scope: 'once' | 'book' | 'library' | null) => {
- switch (scope) {
- case 'once':
- return 'this instance';
- case 'book':
- return 'all instances in this book';
- case 'library':
- return 'all instances in your library';
- default:
- return '';
- }
- };
-
- // Secondary confirmation dialog
- if (showConfirmation) {
- return (
-
-
-
Confirm Replacement
-
- Replace: "{selectedText}"
-
-
- With: "{replacementText}"
-
-
- Scope: {getScopeLabel(selectedScope)}
-
-
- Case sensitive: {caseSensitive ? 'Yes' : 'No'}
-
-
-
-
-
- Back
-
-
- Confirm
-
-
-
- );
- }
-
- return (
-
- {/* Selected text preview */}
-
- Selected:
-
- "{selectedText.length > 50 ? selectedText.substring(0, 50) + '...' : selectedText}
- "
-
-
-
- {/* Replacement text input */}
-
-
- Replace with:
-
- 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 */}
-
- setCaseSensitive(e.target.checked)}
- className='h-4 w-4 rounded border-gray-500 bg-gray-600 text-blue-500 focus:ring-blue-500 focus:ring-offset-gray-700'
- />
- Case Sensitive
-
-
- {/* Scope buttons */}
-
- handleScopeClick('once')}
- disabled={!replacementText.trim()}
- className={clsx(
- 'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
- replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
- )}
- >
- Fix this once
-
- handleScopeClick('book')}
- disabled={!replacementText.trim()}
- className={clsx(
- 'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
- replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
- )}
- >
- Fix in this book
-
- handleScopeClick('library')}
- disabled={!replacementText.trim()}
- className={clsx(
- 'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
- replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
- )}
- >
- Fix in library
-
-
-
- {/* Cancel button */}
-
- Cancel
-
-
- );
-};
-
-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