forked from akai/readest
refactor(proofread): refactor UI and i18n for proofread tool (#2783)
This commit is contained in:
@@ -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": "لا توجد قواعد استبدال على مستوى الكتاب"
|
||||
}
|
||||
|
||||
@@ -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": "কোনও বই-স্তরের বিকল্প নিয়ম নেই"
|
||||
}
|
||||
|
||||
@@ -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": "དེབ་རིམ་གྱི་བརྗེ་སྒྱུར་སྲིད་སྒྲིག་མེད་པ"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Δεν υπάρχουν κανόνες αντικατάστασης σε επίπεδο βιβλίου"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "هیچ قانون جایگزینی در سطح کتاب وجود ندارد"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "पुस्तक स्तर पर कोई प्रतिस्थापन नियम नहीं हैं"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "書籍レベルの置換ルールはありません"
|
||||
}
|
||||
|
||||
@@ -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": "책 수준의 대체 규칙이 없습니다"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Нет правил замены на уровне книги"
|
||||
}
|
||||
|
||||
@@ -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": "පොත් මට්ටමේ ප්රතිස්ථාපන නියමයන් නොමැත"
|
||||
}
|
||||
|
||||
@@ -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å"
|
||||
}
|
||||
|
||||
@@ -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": "புத்தக மட்டத்தில் மாற்று விதிகள் இல்லை"
|
||||
}
|
||||
|
||||
@@ -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": "ไม่มีกฎการแทนที่ระดับหนังสือ"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Немає правил заміни на рівні книги"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "暂无书籍级别的替换规则"
|
||||
}
|
||||
|
||||
@@ -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": "沒有書籍層級的取代規則"
|
||||
}
|
||||
|
||||
@@ -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(<EnvProvider>{ui}</EnvProvider>);
|
||||
}
|
||||
|
||||
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(<ProofreadPopup {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Current selection')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the replacement text input field', () => {
|
||||
renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter text...');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the case sensitive checkbox', () => {
|
||||
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Case sensitive:')).toBeTruthy();
|
||||
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the Apply button', () => {
|
||||
renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Apply')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display selected text preview', () => {
|
||||
renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
||||
|
||||
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(<ProofreadPopup {...defaultProps} />);
|
||||
|
||||
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle when clicked', async () => {
|
||||
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
||||
|
||||
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(<ProofreadPopup {...defaultProps} />);
|
||||
|
||||
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(<ProofreadPopup {...defaultProps} />);
|
||||
|
||||
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(<ProofreadPopup {...createValidSelection()} />);
|
||||
|
||||
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(<ProofreadPopup {...createValidSelection()} />);
|
||||
|
||||
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(<ProofreadPopup {...createValidSelection()} />);
|
||||
|
||||
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(<ProofreadPopup {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter text...');
|
||||
fireEvent.mouseDown(input);
|
||||
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(<EnvProvider>{ui}</EnvProvider>);
|
||||
}
|
||||
|
||||
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(<ProofreadRulesManager />);
|
||||
// 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(<ProofreadRulesManager />);
|
||||
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(<ProofreadRulesManager />);
|
||||
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(<ProofreadRulesManager />);
|
||||
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(
|
||||
<div>
|
||||
<BookMenu />
|
||||
<ProofreadRulesManager />
|
||||
</div>,
|
||||
);
|
||||
|
||||
// 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(<ProofreadRulesManager />);
|
||||
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(<ProofreadRulesManager />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter replacement text...');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the Case Sensitive checkbox', () => {
|
||||
const { container } = render(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Case Sensitive')).toBeTruthy();
|
||||
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the Cancel button', () => {
|
||||
render(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Cancel')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display selected text preview', () => {
|
||||
render(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} selectedText={longText} />);
|
||||
|
||||
// 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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle when clicked', async () => {
|
||||
const { container } = render(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
// 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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
// 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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} selectedText='original' />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
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(
|
||||
<div>
|
||||
<div data-testid='outside'>Outside element</div>
|
||||
<ReplacementOptions {...defaultProps} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
const outsideElement = screen.getByTestId('outside');
|
||||
fireEvent.mouseDown(outsideElement);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onClose when clicking inside the menu', () => {
|
||||
render(<ReplacementOptions {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter replacement text...');
|
||||
fireEvent.mouseDown(input);
|
||||
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(<EnvProvider>{ui}</EnvProvider>);
|
||||
}
|
||||
|
||||
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(<ReplacementRulesWindow />);
|
||||
// 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(<ReplacementRulesWindow />);
|
||||
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(
|
||||
<div>
|
||||
<BookMenu />
|
||||
<ReplacementRulesWindow />
|
||||
</div>,
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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', () => {
|
||||
|
||||
+16
-44
@@ -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', () => {
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className='flex flex-col gap-3 p-3'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<label className='text-base-content/70 text-xs font-medium'>{_('Selected text:')}</label>
|
||||
<input
|
||||
className='input input-sm bg-base-200 text-sm opacity-60'
|
||||
value={editingData.pattern}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<label className='text-base-content/70 text-xs font-medium'>{_('Replace with:')}</label>
|
||||
<input
|
||||
className='input input-sm text-sm'
|
||||
value={editingData.replacement}
|
||||
onChange={(e) => onEditChange('replacement', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-1 flex gap-2'>
|
||||
<button className='btn btn-primary btn-sm flex-1' onClick={onSave}>
|
||||
{_('Save')}
|
||||
</button>
|
||||
<button className='btn btn-sm flex-1' onClick={onCancel}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-start justify-between gap-3 p-3'>
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-1.5'>
|
||||
<div className='break-words text-base font-medium leading-snug'>{rule.pattern}</div>
|
||||
<div className='text-base-content/70 break-words text-sm'>
|
||||
<span className='text-base-content/80 mr-1.5 text-xs font-medium'>
|
||||
{_('Replace with:')}
|
||||
</span>
|
||||
<span className='text-base-content/90'>{rule.replacement}</span>
|
||||
</div>
|
||||
<div className='text-base-content/60 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs'>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<span className='text-base-content/50'>{_('Scope:')}</span>
|
||||
<span
|
||||
role='none'
|
||||
className={clsx(
|
||||
'text-base-content/70 font-medium',
|
||||
scope === 'selection' && 'cursor-pointer text-blue-400 hover:text-blue-500',
|
||||
)}
|
||||
onClick={scope === 'selection' ? navigateToSelection : undefined}
|
||||
>
|
||||
{scope === 'selection' ? _('Selection') : scope === 'book' ? _('Book') : _('Library')}
|
||||
</span>
|
||||
</span>
|
||||
<span className='text-base-content/30'>•</span>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<span className='text-base-content/50'>{_('Case sensitive:')}</span>
|
||||
<span className='text-base-content/70 font-medium'>
|
||||
{rule.caseSensitive !== false ? _('Yes') : _('No')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1'>
|
||||
<button
|
||||
className='btn btn-ghost btn-sm h-8 w-8 p-0'
|
||||
onClick={onEdit}
|
||||
aria-label={_('Edit')}
|
||||
>
|
||||
<RiEditLine className='h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
className='btn btn-ghost btn-sm h-8 w-8 p-0'
|
||||
onClick={onDelete}
|
||||
aria-label={_('Delete')}
|
||||
>
|
||||
<RiDeleteBin7Line className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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,
|
||||
) => (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<h3 className='text-base-content/90 text-sm font-semibold'>{title}</h3>
|
||||
{rules.length === 0 ? (
|
||||
<div className='border-base-content/10 bg-base-200/30 rounded-lg border border-dashed p-6 text-center'>
|
||||
<p className='text-base-content/50 text-sm'>{emptyMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className='flex flex-col gap-2'>
|
||||
{rules.map((rule) => (
|
||||
<li
|
||||
key={rule.id}
|
||||
className='border-base-content/10 bg-base-100 hover:border-base-content/20 rounded-lg border transition-colors'
|
||||
>
|
||||
<RuleItem
|
||||
rule={rule}
|
||||
scope={scopeType === 'selection' ? 'selection' : rule.scope}
|
||||
isEditing={
|
||||
editing.id === rule.id &&
|
||||
editing.scope === (scopeType === 'selection' ? 'selection' : rule.scope)
|
||||
}
|
||||
editingData={editing}
|
||||
onEdit={() => 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 })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id={dialogId}
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={_('Proofread Replacement Rules')}
|
||||
boxClassName='sm:!min-w-[560px] sm:!max-w-[640px] sm:h-auto'
|
||||
>
|
||||
{isOpen && (
|
||||
<div className='flex max-h-[70vh] flex-col gap-6 p-4 sm:p-6'>
|
||||
{renderRuleList(
|
||||
singleRules,
|
||||
'selection',
|
||||
_('Selected Text Rules'),
|
||||
_('No selected text replacement rules'),
|
||||
)}
|
||||
{renderRuleList(
|
||||
bookRules,
|
||||
'book',
|
||||
_('Book Specific Rules'),
|
||||
_('No book-level replacement rules'),
|
||||
)}
|
||||
<div className='p-1'></div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProofreadRulesManager;
|
||||
@@ -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 }) => {
|
||||
<AboutWindow />
|
||||
<UpdaterWindow />
|
||||
<KOSyncSettingsWindow />
|
||||
<ReplacementRulesWindow />
|
||||
<ProofreadRulesManager />
|
||||
<Toast />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
id='replacement_rules_window'
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={_('Replacement Rules')}
|
||||
boxClassName='sm:!min-w-[520px] sm:h-auto'
|
||||
>
|
||||
{isOpen && (
|
||||
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold'>{_('Single Instance Rules')}</h3>
|
||||
{singleRules.length === 0 ? (
|
||||
<p className='text-base-content/70 mt-2 text-sm'>
|
||||
{_('No single replacement rules')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className='mt-2 space-y-2'>
|
||||
{singleRules.map((r) => (
|
||||
<li key={r.id} className='rounded border p-2'>
|
||||
{editing.id === r.id && editing.scope === 'single' ? (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<label className='text-base-content/70 whitespace-nowrap text-xs'>
|
||||
{_('Selected phrase:')}
|
||||
</label>
|
||||
<input
|
||||
className='input input-sm flex-1 text-sm opacity-60'
|
||||
value={editing.pattern}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<label className='text-base-content/70 whitespace-nowrap text-xs'>
|
||||
{_('Replace with:')}
|
||||
</label>
|
||||
<input
|
||||
className='input input-sm flex-1'
|
||||
value={editing.replacement}
|
||||
onChange={(e) =>
|
||||
setEditing({ ...editing, replacement: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<button className='btn btn-sm btn-primary' onClick={saveEdit}>
|
||||
{_('Save')}
|
||||
</button>
|
||||
<button className='btn btn-sm' onClick={cancelEdit}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='text-base font-medium leading-tight'>{r.pattern}</div>
|
||||
<div className='text-base-content/70 mt-1 break-all text-sm'>
|
||||
<span className='text-base-content/80 mr-2 text-xs font-medium'>
|
||||
{_('Replace with:')}
|
||||
</span>
|
||||
{r.replacement}
|
||||
</div>
|
||||
<div className='text-base-content/60 mt-1 text-xs'>
|
||||
{_('Scope:')} <span className='font-medium'>Single Instance</span>
|
||||
| {_('Case sensitive:')}
|
||||
<span className='font-medium'>
|
||||
{r.caseSensitive !== false ? _('Yes') : _('No')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<button
|
||||
className='btn btn-ghost btn-xs p-1'
|
||||
onClick={() => startEdit(r, 'single')}
|
||||
aria-label={_('Edit')}
|
||||
>
|
||||
<RiEditLine />
|
||||
</button>
|
||||
<button
|
||||
className='btn btn-ghost btn-xs p-1'
|
||||
onClick={() => deleteRule(r.id, 'single')}
|
||||
aria-label={_('Delete')}
|
||||
>
|
||||
<RiDeleteBin7Line />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3 className='mt-4 text-sm font-semibold'>{_('Book Specific Rules')}</h3>
|
||||
{bookRules.length === 0 ? (
|
||||
<p className='text-base-content/70 mt-2 text-sm'>
|
||||
{_('No book-level replacement rules')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className='mt-2 space-y-2'>
|
||||
{bookRules.map((r) => {
|
||||
const ruleScope = getRuleScope(r);
|
||||
const isEditing = editing.id === r.id && editing.scope === ruleScope;
|
||||
return (
|
||||
<li key={r.id} className='rounded border p-2'>
|
||||
{isEditing ? (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<label className='text-base-content/70 whitespace-nowrap text-xs'>
|
||||
{_('Selected phrase:')}
|
||||
</label>
|
||||
<input
|
||||
className='input input-sm flex-1 text-sm opacity-60'
|
||||
value={editing.pattern}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<label className='text-base-content/70 whitespace-nowrap text-xs'>
|
||||
{_('Replace with:')}
|
||||
</label>
|
||||
<input
|
||||
className='input input-sm flex-1'
|
||||
value={editing.replacement}
|
||||
onChange={(e) =>
|
||||
setEditing({ ...editing, replacement: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<button className='btn btn-sm btn-primary' onClick={saveEdit}>
|
||||
{_('Save')}
|
||||
</button>
|
||||
<button className='btn btn-sm' onClick={cancelEdit}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='text-base font-medium leading-tight'>{r.pattern}</div>
|
||||
<div className='text-base-content/70 mt-1 break-all text-sm'>
|
||||
<span className='text-base-content/80 mr-2 text-xs font-medium'>
|
||||
{_('Replace with:')}
|
||||
</span>
|
||||
{r.replacement}
|
||||
</div>
|
||||
<div className='text-base-content/60 mt-1 text-xs'>
|
||||
{_('Scope:')}
|
||||
<span className='font-medium'>
|
||||
{getRuleScope(r) === 'book' ? _('Book') : _('Global')}
|
||||
</span>
|
||||
| {_('Case sensitive:')}
|
||||
<span className='font-medium'>
|
||||
{r.caseSensitive !== false ? _('Yes') : _('No')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<button
|
||||
className='btn btn-ghost btn-xs p-1'
|
||||
onClick={() => startEdit(r, getRuleScope(r))}
|
||||
aria-label={_('Edit')}
|
||||
>
|
||||
<RiEditLine />
|
||||
</button>
|
||||
<button
|
||||
className='btn btn-ghost btn-xs p-1'
|
||||
onClick={() => deleteRule(r.id, ruleScope)}
|
||||
aria-label={_('Delete')}
|
||||
>
|
||||
<RiDeleteBin7Line />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplacementRulesWindow;
|
||||
@@ -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<Position>();
|
||||
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
|
||||
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
|
||||
const [translatorPopupPosition, setTranslatorPopupPosition] = useState<Position>();
|
||||
const [proofreadPopupPosition, setProofreadPopupPosition] = useState<Position>();
|
||||
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
|
||||
|
||||
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(
|
||||
@@ -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 && (
|
||||
<ReplacementOptions
|
||||
isVertical={viewSettings.vertical}
|
||||
style={{
|
||||
height: 'auto',
|
||||
left: `${annotPopupPosition.point.x}px`,
|
||||
top: `${
|
||||
annotPopupPosition.point.y +
|
||||
(annotPopupHeight + 16) * (trianglePosition.dir === 'up' ? -1 : 1)
|
||||
}px`,
|
||||
}}
|
||||
selectedText={selection?.text || ''}
|
||||
onConfirm={handleReplacementConfirm}
|
||||
onClose={() => setShowReplacementOptions(false)}
|
||||
{showProofreadPopup && trianglePosition && proofreadPopupPosition && selection && (
|
||||
<ProofreadPopup
|
||||
bookKey={bookKey}
|
||||
selection={selection}
|
||||
position={proofreadPopupPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
popupWidth={proofreadPopupWidth}
|
||||
popupHeight={proofreadPopupHeight}
|
||||
onDismiss={handleDismissPopupAndSelection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ const PopupButton: React.FC<PopupButtonProps> = ({
|
||||
return (
|
||||
<div
|
||||
className='lg:tooltip lg:tooltip-bottom'
|
||||
data-tip={!buttonClicked && showTooltip ? tooltipText : null}
|
||||
title={!buttonClicked && showTooltip ? tooltipText : undefined}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useAutoFocus } from '@/hooks/useAutoFocus';
|
||||
import { CreateProofreadRuleOptions, useProofreadStore } from '@/store/proofreadStore';
|
||||
import { ProofreadScope } from '@/types/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { Position, TextSelection } from '@/utils/sel';
|
||||
import { isWholeWord } from '@/utils/word';
|
||||
import Select from '@/components/Select';
|
||||
import Popup from '@/components/Popup';
|
||||
|
||||
interface ProofreadPopupProps {
|
||||
bookKey: string;
|
||||
selection?: TextSelection;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
popupWidth: number;
|
||||
popupHeight: number;
|
||||
onConfirm?: (options: CreateProofreadRuleOptions) => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
|
||||
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<ProofreadScope>('selection');
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useAutoFocus<HTMLInputElement>({ ref: inputRef });
|
||||
|
||||
const handleScopeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
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 (
|
||||
<div>
|
||||
<Popup
|
||||
trianglePosition={trianglePosition}
|
||||
width={popupWidth}
|
||||
minHeight={popupHeight}
|
||||
position={position}
|
||||
className='flex flex-col justify-between rounded-lg bg-gray-700 text-gray-400'
|
||||
triangleClassName='text-gray-700'
|
||||
onDismiss={onDismiss}
|
||||
>
|
||||
<div className='flex flex-col gap-6 p-4'>
|
||||
<div className='flex gap-1 text-xs text-gray-400'>
|
||||
<span>{_('Selected text:')}</span>
|
||||
<span className='line-clamp-1 select-text break-words text-yellow-300'>
|
||||
"{selection?.text || ''}"
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<label htmlFor='replacement-input' className='text-xs'>
|
||||
{_('Replace with:')}
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
value={replacementText}
|
||||
onChange={(e) => 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'
|
||||
/>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={!replacementText.trim()}
|
||||
className={clsx(
|
||||
'btn btn-sm btn-ghost text-blue-600 disabled:text-gray-600',
|
||||
'bg-transparent hover:bg-transparent disabled:bg-transparent',
|
||||
)}
|
||||
>
|
||||
{_('Apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between gap-4 p-4'>
|
||||
<label className='flex max-w-[30%] cursor-pointer items-center gap-2'>
|
||||
<span className='line-clamp-1 text-xs' title={_('Case sensitive:')}>
|
||||
{_('Case sensitive:')}
|
||||
</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle toggle-sm bg-gray-500 checked:bg-black hover:bg-gray-500 hover:checked:bg-black'
|
||||
style={
|
||||
{
|
||||
'--tglbg': '#4B5563',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
checked={caseSensitive}
|
||||
onChange={(e) => setCaseSensitive(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className='flex max-w-[65%] flex-1 items-center justify-between gap-2'>
|
||||
<label htmlFor='scope-select' className='line-clamp-1 text-xs' title={_('Scope:')}>
|
||||
{_('Scope:')}
|
||||
</label>
|
||||
<Select
|
||||
className='max-w-[50%] bg-gray-600 text-white'
|
||||
value={scope}
|
||||
onChange={handleScopeChange}
|
||||
options={scopeOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProofreadPopup;
|
||||
@@ -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<ReplacementOptionsProps> = ({
|
||||
style,
|
||||
isVertical,
|
||||
selectedText,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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<React.CSSProperties | null>(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 (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={clsx(
|
||||
'replacement-options absolute flex flex-col gap-3 rounded-lg bg-gray-700 p-4',
|
||||
)}
|
||||
style={{
|
||||
...(adjustedStyle || style),
|
||||
minWidth: '320px',
|
||||
maxHeight: 'calc(100vh - 40px)',
|
||||
overflowY: 'auto',
|
||||
visibility: isPositioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className='text-sm text-white'>
|
||||
<p className='mb-2 font-semibold'>Confirm Replacement</p>
|
||||
<p className='mb-1 text-gray-300'>
|
||||
Replace: <span className='text-yellow-300'>"{selectedText}"</span>
|
||||
</p>
|
||||
<p className='mb-1 text-gray-300'>
|
||||
With: <span className='text-green-300'>"{replacementText}"</span>
|
||||
</p>
|
||||
<p className='mb-1 text-gray-300'>
|
||||
Scope: <span className='text-blue-300'>{getScopeLabel(selectedScope)}</span>
|
||||
</p>
|
||||
<p className='text-gray-300'>
|
||||
Case sensitive: <span className='text-purple-300'>{caseSensitive ? 'Yes' : 'No'}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mt-2 flex gap-2'>
|
||||
<button
|
||||
onClick={handleCancelConfirmation}
|
||||
className='flex-1 rounded-md bg-gray-600 px-3 py-2 text-sm text-white transition-colors hover:bg-gray-500'
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className='flex-1 rounded-md bg-green-600 px-3 py-2 text-sm text-white transition-colors hover:bg-green-500'
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={clsx(
|
||||
'replacement-options absolute flex flex-col gap-3 rounded-lg bg-gray-700 p-4',
|
||||
isVertical ? 'flex-col' : 'flex-col',
|
||||
)}
|
||||
style={{
|
||||
...(adjustedStyle || style),
|
||||
minWidth: '280px',
|
||||
maxHeight: 'calc(100vh - 40px)',
|
||||
overflowY: 'auto',
|
||||
visibility: isPositioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Selected text preview */}
|
||||
<div className='text-xs text-gray-400'>
|
||||
<span>Selected: </span>
|
||||
<span className='break-words text-yellow-300'>
|
||||
"{selectedText.length > 50 ? selectedText.substring(0, 50) + '...' : selectedText}
|
||||
"
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Replacement text input */}
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label htmlFor='replacement-input' className='text-xs text-gray-400'>
|
||||
Replace with:
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id='replacement-input'
|
||||
type='text'
|
||||
value={replacementText}
|
||||
onChange={(e) => 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'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Case sensitivity checkbox */}
|
||||
<label className='flex cursor-pointer items-center gap-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={caseSensitive}
|
||||
onChange={(e) => 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'
|
||||
/>
|
||||
<span className='text-sm text-white'>Case Sensitive</span>
|
||||
</label>
|
||||
|
||||
{/* Scope buttons */}
|
||||
<div className='mt-1 flex flex-col gap-1'>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cancel button */}
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className='mt-2 rounded-md bg-gray-600 px-3 py-2 text-sm text-white transition-colors hover:bg-gray-500'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplacementOptions;
|
||||
@@ -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<BookMenuProps> = ({ 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<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
))}
|
||||
<hr className='border-base-200 my-1' />
|
||||
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
|
||||
{false && <MenuItem label={_('Replacement Rules')} onClick={showReplacementRulesWindow} />}
|
||||
{settings.kosync.enabled && (
|
||||
<>
|
||||
<MenuItem label={_('Push Progress')} onClick={handlePushKOSync} />
|
||||
@@ -153,6 +152,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
</>
|
||||
)}
|
||||
<hr className='border-base-200 my-1' />
|
||||
<MenuItem label={_('Proofread')} onClick={showProofreadRulesWindow} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
<MenuItem label={_('Export Annotations')} onClick={handleExportAnnotations} />
|
||||
<MenuItem
|
||||
label={_('Sort TOC by Page')}
|
||||
|
||||
@@ -53,7 +53,13 @@ export const useTextSelector = (
|
||||
sel.removeAllRanges();
|
||||
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,
|
||||
});
|
||||
};
|
||||
// FIXME: extremely hacky way to dismiss system selection tools on iOS
|
||||
const makeSelectionOnIOS = async (sel: Selection, index: number) => {
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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<string, ReplacementRule>();
|
||||
|
||||
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<ReplacementRule> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Omit<ReplacementRule, 'id'>>,
|
||||
scope: ReplacementRuleScope,
|
||||
): Promise<void> {
|
||||
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<Omit<ReplacementRule, 'id'>>,
|
||||
persist: boolean,
|
||||
): Promise<void> {
|
||||
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<Omit<ReplacementRule, 'id'>>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<ProofreadRule>;
|
||||
updateRule: (
|
||||
envConfig: EnvConfigType,
|
||||
bookKey: string,
|
||||
ruleId: string,
|
||||
updates: Partial<Omit<ProofreadRule, 'id'>>,
|
||||
) => Promise<void>;
|
||||
removeRule: (
|
||||
envConfig: EnvConfigType,
|
||||
bookKey: string,
|
||||
ruleId: string,
|
||||
scope: ProofreadScope,
|
||||
) => Promise<void>;
|
||||
toggleRule: (envConfig: EnvConfigType, bookKey: string, ruleId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
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<string, ProofreadRule>();
|
||||
|
||||
(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<ProofreadStoreState>(() => ({
|
||||
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<void> {
|
||||
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<Omit<ProofreadRule, 'id'>>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Omit<ProofreadRule, 'id'>>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface TextSelection {
|
||||
text: string;
|
||||
range: Range;
|
||||
index: number;
|
||||
cfi?: string;
|
||||
href?: string;
|
||||
annotated?: boolean;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
+1
-1
Submodule packages/foliate-js updated: d15091c523...43f30812d1
Reference in New Issue
Block a user