refactor(proofread): refactor UI and i18n for proofread tool (#2783)

This commit is contained in:
Huang Xin
2025-12-24 21:46:33 +08:00
committed by GitHub
parent 3abef6ea75
commit 6984393ed1
54 changed files with 3022 additions and 2965 deletions
@@ -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', () => {
@@ -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:')}&nbsp;<span className='font-medium'>Single Instance</span>
&nbsp;|&nbsp;{_('Case sensitive:')}&nbsp;
<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:')}&nbsp;
<span className='font-medium'>
{getRuleScope(r) === 'book' ? _('Book') : _('Global')}
</span>
&nbsp;|&nbsp;{_('Case sensitive:')}&nbsp;
<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'>
&quot;{selection?.text || ''}&quot;
</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'>&quot;{selectedText}&quot;</span>
</p>
<p className='mb-1 text-gray-300'>
With: <span className='text-green-300'>&quot;{replacementText}&quot;</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'>
&quot;{selectedText.length > 50 ? selectedText.substring(0, 50) + '...' : selectedText}
&quot;
</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 };
}
+8 -8
View File
@@ -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;
+1
View File
@@ -27,6 +27,7 @@ export interface TextSelection {
text: string;
range: Range;
index: number;
cfi?: string;
href?: string;
annotated?: boolean;
}
+86
View File
@@ -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;
};
-10
View File
@@ -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;
};