forked from akai/readest
* fix(proofread): keep disabled book rules visible in the manager list Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(proofread): add per-rule enable/disable toggle in the manager Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(proofread): allow editing Find pattern, regex, and case on existing rules Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n: add proofread edit and toggle strings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1941,5 +1941,8 @@
|
||||
"Granularity": "الدقة",
|
||||
"Refresh Page": "تحديث الصفحة",
|
||||
"Recently read": "المقروءة مؤخرًا",
|
||||
"Show recently read": "إظهار المقروءة مؤخرًا"
|
||||
"Show recently read": "إظهار المقروءة مؤخرًا",
|
||||
"Find:": "بحث:",
|
||||
"Enable rule": "تفعيل القاعدة",
|
||||
"Disable rule": "تعطيل القاعدة"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "সূক্ষ্মতা",
|
||||
"Refresh Page": "পৃষ্ঠা রিফ্রেশ করুন",
|
||||
"Recently read": "সম্প্রতি পড়া",
|
||||
"Show recently read": "সম্প্রতি পড়া দেখান"
|
||||
"Show recently read": "সম্প্রতি পড়া দেখান",
|
||||
"Find:": "খুঁজুন:",
|
||||
"Enable rule": "নিয়ম সক্রিয় করুন",
|
||||
"Disable rule": "নিয়ম নিষ্ক্রিয় করুন"
|
||||
}
|
||||
|
||||
@@ -1770,5 +1770,8 @@
|
||||
"Granularity": "ཞིབ་ཕྲའི་ཚད",
|
||||
"Refresh Page": "ཤོག་ལྷེ་བསྐྱར་གསོ།",
|
||||
"Recently read": "ཉེ་ཆར་བཀླགས་པ།",
|
||||
"Show recently read": "ཉེ་ཆར་བཀླགས་པ་སྟོན་པ།"
|
||||
"Show recently read": "ཉེ་ཆར་བཀླགས་པ་སྟོན་པ།",
|
||||
"Find:": "འཚོལ་བཤེར།:",
|
||||
"Enable rule": "སྲིད་སྒྲིག་སྤྱོད་པ།",
|
||||
"Disable rule": "སྲིད་སྒྲིག་སྒོ་བརྒྱབ།"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "Granularität",
|
||||
"Refresh Page": "Seite aktualisieren",
|
||||
"Recently read": "Zuletzt gelesen",
|
||||
"Show recently read": "Zuletzt gelesen anzeigen"
|
||||
"Show recently read": "Zuletzt gelesen anzeigen",
|
||||
"Find:": "Suchen:",
|
||||
"Enable rule": "Regel aktivieren",
|
||||
"Disable rule": "Regel deaktivieren"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "Διαβάθμιση",
|
||||
"Refresh Page": "Ανανέωση σελίδας",
|
||||
"Recently read": "Πρόσφατα αναγνωσμένα",
|
||||
"Show recently read": "Εμφάνιση πρόσφατα αναγνωσμένων"
|
||||
"Show recently read": "Εμφάνιση πρόσφατα αναγνωσμένων",
|
||||
"Find:": "Εύρεση:",
|
||||
"Enable rule": "Ενεργοποίηση κανόνα",
|
||||
"Disable rule": "Απενεργοποίηση κανόνα"
|
||||
}
|
||||
|
||||
@@ -1842,5 +1842,8 @@
|
||||
"Granularity": "Granularidad",
|
||||
"Refresh Page": "Actualizar página",
|
||||
"Recently read": "Leídos recientemente",
|
||||
"Show recently read": "Mostrar leídos recientemente"
|
||||
"Show recently read": "Mostrar leídos recientemente",
|
||||
"Find:": "Buscar:",
|
||||
"Enable rule": "Activar regla",
|
||||
"Disable rule": "Desactivar regla"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "دانهبندی",
|
||||
"Refresh Page": "تازهسازی صفحه",
|
||||
"Recently read": "اخیراً خواندهشده",
|
||||
"Show recently read": "نمایش موارد اخیراً خواندهشده"
|
||||
"Show recently read": "نمایش موارد اخیراً خواندهشده",
|
||||
"Find:": "جستجو:",
|
||||
"Enable rule": "فعال کردن قانون",
|
||||
"Disable rule": "غیرفعال کردن قانون"
|
||||
}
|
||||
|
||||
@@ -1842,5 +1842,8 @@
|
||||
"Granularity": "Granularité",
|
||||
"Refresh Page": "Actualiser la page",
|
||||
"Recently read": "Lectures récentes",
|
||||
"Show recently read": "Afficher les lectures récentes"
|
||||
"Show recently read": "Afficher les lectures récentes",
|
||||
"Find:": "Rechercher :",
|
||||
"Enable rule": "Activer la règle",
|
||||
"Disable rule": "Désactiver la règle"
|
||||
}
|
||||
|
||||
@@ -1842,5 +1842,8 @@
|
||||
"Granularity": "רמת פירוט",
|
||||
"Refresh Page": "רענן דף",
|
||||
"Recently read": "נקראו לאחרונה",
|
||||
"Show recently read": "הצגת ספרים שנקראו לאחרונה"
|
||||
"Show recently read": "הצגת ספרים שנקראו לאחרונה",
|
||||
"Find:": "חיפוש:",
|
||||
"Enable rule": "הפעל כלל",
|
||||
"Disable rule": "השבת כלל"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "सूक्ष्मता",
|
||||
"Refresh Page": "पृष्ठ ताज़ा करें",
|
||||
"Recently read": "हाल ही में पढ़े गए",
|
||||
"Show recently read": "हाल ही में पढ़े गए दिखाएँ"
|
||||
"Show recently read": "हाल ही में पढ़े गए दिखाएँ",
|
||||
"Find:": "खोजें:",
|
||||
"Enable rule": "नियम सक्षम करें",
|
||||
"Disable rule": "नियम अक्षम करें"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "Részletesség",
|
||||
"Refresh Page": "Oldal frissítése",
|
||||
"Recently read": "Nemrég olvasott",
|
||||
"Show recently read": "Nemrég olvasottak megjelenítése"
|
||||
"Show recently read": "Nemrég olvasottak megjelenítése",
|
||||
"Find:": "Keresés:",
|
||||
"Enable rule": "Szabály engedélyezése",
|
||||
"Disable rule": "Szabály letiltása"
|
||||
}
|
||||
|
||||
@@ -1776,5 +1776,8 @@
|
||||
"Granularity": "Granularitas",
|
||||
"Refresh Page": "Segarkan Halaman",
|
||||
"Recently read": "Baru dibaca",
|
||||
"Show recently read": "Tampilkan baru dibaca"
|
||||
"Show recently read": "Tampilkan baru dibaca",
|
||||
"Find:": "Cari:",
|
||||
"Enable rule": "Aktifkan aturan",
|
||||
"Disable rule": "Nonaktifkan aturan"
|
||||
}
|
||||
|
||||
@@ -1842,5 +1842,8 @@
|
||||
"Granularity": "Granularità",
|
||||
"Refresh Page": "Aggiorna pagina",
|
||||
"Recently read": "Letti di recente",
|
||||
"Show recently read": "Mostra letti di recente"
|
||||
"Show recently read": "Mostra letti di recente",
|
||||
"Find:": "Trova:",
|
||||
"Enable rule": "Attiva regola",
|
||||
"Disable rule": "Disattiva regola"
|
||||
}
|
||||
|
||||
@@ -1776,5 +1776,8 @@
|
||||
"Granularity": "粒度",
|
||||
"Refresh Page": "ページを更新",
|
||||
"Recently read": "最近読んだ本",
|
||||
"Show recently read": "最近読んだ本を表示"
|
||||
"Show recently read": "最近読んだ本を表示",
|
||||
"Find:": "検索:",
|
||||
"Enable rule": "ルールを有効化",
|
||||
"Disable rule": "ルールを無効化"
|
||||
}
|
||||
|
||||
@@ -1776,5 +1776,8 @@
|
||||
"Granularity": "단위",
|
||||
"Refresh Page": "페이지 새로고침",
|
||||
"Recently read": "최근 읽은 책",
|
||||
"Show recently read": "최근 읽은 책 표시"
|
||||
"Show recently read": "최근 읽은 책 표시",
|
||||
"Find:": "찾기:",
|
||||
"Enable rule": "규칙 활성화",
|
||||
"Disable rule": "규칙 비활성화"
|
||||
}
|
||||
|
||||
@@ -1776,5 +1776,8 @@
|
||||
"Granularity": "Granulariti",
|
||||
"Refresh Page": "Segarkan Halaman",
|
||||
"Recently read": "Baru dibaca",
|
||||
"Show recently read": "Tunjukkan baru dibaca"
|
||||
"Show recently read": "Tunjukkan baru dibaca",
|
||||
"Find:": "Cari:",
|
||||
"Enable rule": "Dayakan peraturan",
|
||||
"Disable rule": "Nyahdayakan peraturan"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "Granulariteit",
|
||||
"Refresh Page": "Pagina vernieuwen",
|
||||
"Recently read": "Onlangs gelezen",
|
||||
"Show recently read": "Onlangs gelezen tonen"
|
||||
"Show recently read": "Onlangs gelezen tonen",
|
||||
"Find:": "Zoeken:",
|
||||
"Enable rule": "Regel inschakelen",
|
||||
"Disable rule": "Regel uitschakelen"
|
||||
}
|
||||
|
||||
@@ -1875,5 +1875,8 @@
|
||||
"Granularity": "Ziarnistość",
|
||||
"Refresh Page": "Odśwież stronę",
|
||||
"Recently read": "Ostatnio czytane",
|
||||
"Show recently read": "Pokaż ostatnio czytane"
|
||||
"Show recently read": "Pokaż ostatnio czytane",
|
||||
"Find:": "Znajdź:",
|
||||
"Enable rule": "Włącz regułę",
|
||||
"Disable rule": "Wyłącz regułę"
|
||||
}
|
||||
|
||||
@@ -1842,5 +1842,8 @@
|
||||
"Granularity": "Granularidade",
|
||||
"Refresh Page": "Atualizar página",
|
||||
"Recently read": "Lidos recentemente",
|
||||
"Show recently read": "Mostrar lidos recentemente"
|
||||
"Show recently read": "Mostrar lidos recentemente",
|
||||
"Find:": "Localizar:",
|
||||
"Enable rule": "Ativar regra",
|
||||
"Disable rule": "Desativar regra"
|
||||
}
|
||||
|
||||
@@ -1842,5 +1842,8 @@
|
||||
"Granularity": "Granularidade",
|
||||
"Refresh Page": "Atualizar Página",
|
||||
"Recently read": "Lidos recentemente",
|
||||
"Show recently read": "Mostrar lidos recentemente"
|
||||
"Show recently read": "Mostrar lidos recentemente",
|
||||
"Find:": "Localizar:",
|
||||
"Enable rule": "Ativar regra",
|
||||
"Disable rule": "Desativar regra"
|
||||
}
|
||||
|
||||
@@ -1842,5 +1842,8 @@
|
||||
"Granularity": "Granularitate",
|
||||
"Refresh Page": "Reîmprospătează pagina",
|
||||
"Recently read": "Citite recent",
|
||||
"Show recently read": "Afișează citite recent"
|
||||
"Show recently read": "Afișează citite recent",
|
||||
"Find:": "Găsește:",
|
||||
"Enable rule": "Activează regula",
|
||||
"Disable rule": "Dezactivează regula"
|
||||
}
|
||||
|
||||
@@ -1875,5 +1875,8 @@
|
||||
"Granularity": "Детализация",
|
||||
"Refresh Page": "Обновить страницу",
|
||||
"Recently read": "Недавно прочитанные",
|
||||
"Show recently read": "Показывать недавно прочитанные"
|
||||
"Show recently read": "Показывать недавно прочитанные",
|
||||
"Find:": "Найти:",
|
||||
"Enable rule": "Включить правило",
|
||||
"Disable rule": "Отключить правило"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "විස්තර මට්ටම",
|
||||
"Refresh Page": "පිටුව නැවුම් කරන්න",
|
||||
"Recently read": "මෑතකදී කියවූ",
|
||||
"Show recently read": "මෑතකදී කියවූ ඒවා පෙන්වන්න"
|
||||
"Show recently read": "මෑතකදී කියවූ ඒවා පෙන්වන්න",
|
||||
"Find:": "සොයන්න:",
|
||||
"Enable rule": "නියමය සක්රීය කරන්න",
|
||||
"Disable rule": "නියමය අක්රීය කරන්න"
|
||||
}
|
||||
|
||||
@@ -1875,5 +1875,8 @@
|
||||
"Granularity": "Granularnost",
|
||||
"Refresh Page": "Osveži stran",
|
||||
"Recently read": "Nedavno prebrano",
|
||||
"Show recently read": "Pokaži nedavno prebrano"
|
||||
"Show recently read": "Pokaži nedavno prebrano",
|
||||
"Find:": "Najdi:",
|
||||
"Enable rule": "Omogoči pravilo",
|
||||
"Disable rule": "Onemogoči pravilo"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "Detaljnivå",
|
||||
"Refresh Page": "Uppdatera sidan",
|
||||
"Recently read": "Nyligen lästa",
|
||||
"Show recently read": "Visa nyligen lästa"
|
||||
"Show recently read": "Visa nyligen lästa",
|
||||
"Find:": "Sök:",
|
||||
"Enable rule": "Aktivera regel",
|
||||
"Disable rule": "Inaktivera regel"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "நுண்மை",
|
||||
"Refresh Page": "பக்கத்தைப் புதுப்பி",
|
||||
"Recently read": "சமீபத்தில் படித்தவை",
|
||||
"Show recently read": "சமீபத்தில் படித்தவற்றைக் காட்டு"
|
||||
"Show recently read": "சமீபத்தில் படித்தவற்றைக் காட்டு",
|
||||
"Find:": "தேடு:",
|
||||
"Enable rule": "விதியை இயக்கு",
|
||||
"Disable rule": "விதியை முடக்கு"
|
||||
}
|
||||
|
||||
@@ -1776,5 +1776,8 @@
|
||||
"Granularity": "ระดับความละเอียด",
|
||||
"Refresh Page": "รีเฟรชหน้า",
|
||||
"Recently read": "อ่านล่าสุด",
|
||||
"Show recently read": "แสดงรายการอ่านล่าสุด"
|
||||
"Show recently read": "แสดงรายการอ่านล่าสุด",
|
||||
"Find:": "ค้นหา:",
|
||||
"Enable rule": "เปิดใช้กฎ",
|
||||
"Disable rule": "ปิดใช้กฎ"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "Ayrıntı düzeyi",
|
||||
"Refresh Page": "Sayfayı Yenile",
|
||||
"Recently read": "Son okunanlar",
|
||||
"Show recently read": "Son okunanları göster"
|
||||
"Show recently read": "Son okunanları göster",
|
||||
"Find:": "Bul:",
|
||||
"Enable rule": "Kuralı etkinleştir",
|
||||
"Disable rule": "Kuralı devre dışı bırak"
|
||||
}
|
||||
|
||||
@@ -1875,5 +1875,8 @@
|
||||
"Granularity": "Деталізація",
|
||||
"Refresh Page": "Оновити сторінку",
|
||||
"Recently read": "Нещодавно прочитані",
|
||||
"Show recently read": "Показувати нещодавно прочитані"
|
||||
"Show recently read": "Показувати нещодавно прочитані",
|
||||
"Find:": "Знайти:",
|
||||
"Enable rule": "Увімкнути правило",
|
||||
"Disable rule": "Вимкнути правило"
|
||||
}
|
||||
|
||||
@@ -1809,5 +1809,8 @@
|
||||
"Granularity": "Tafsilot darajasi",
|
||||
"Refresh Page": "Sahifani yangilash",
|
||||
"Recently read": "Yaqinda o'qilgan",
|
||||
"Show recently read": "Yaqinda o'qilganlarni ko'rsatish"
|
||||
"Show recently read": "Yaqinda o'qilganlarni ko'rsatish",
|
||||
"Find:": "Topish:",
|
||||
"Enable rule": "Qoidani yoqish",
|
||||
"Disable rule": "Qoidani o‘chirish"
|
||||
}
|
||||
|
||||
@@ -1776,5 +1776,8 @@
|
||||
"Granularity": "Mức độ chi tiết",
|
||||
"Refresh Page": "Làm mới trang",
|
||||
"Recently read": "Đã đọc gần đây",
|
||||
"Show recently read": "Hiển thị mục đã đọc gần đây"
|
||||
"Show recently read": "Hiển thị mục đã đọc gần đây",
|
||||
"Find:": "Tìm:",
|
||||
"Enable rule": "Bật quy tắc",
|
||||
"Disable rule": "Tắt quy tắc"
|
||||
}
|
||||
|
||||
@@ -1776,5 +1776,8 @@
|
||||
"Granularity": "粒度",
|
||||
"Refresh Page": "刷新页面",
|
||||
"Recently read": "最近阅读",
|
||||
"Show recently read": "显示最近阅读"
|
||||
"Show recently read": "显示最近阅读",
|
||||
"Find:": "查找:",
|
||||
"Enable rule": "启用规则",
|
||||
"Disable rule": "禁用规则"
|
||||
}
|
||||
|
||||
@@ -1776,5 +1776,8 @@
|
||||
"Granularity": "粒度",
|
||||
"Refresh Page": "重新整理頁面",
|
||||
"Recently read": "最近閱讀",
|
||||
"Show recently read": "顯示最近閱讀"
|
||||
"Show recently read": "顯示最近閱讀",
|
||||
"Find:": "尋找:",
|
||||
"Enable rule": "啟用規則",
|
||||
"Disable rule": "停用規則"
|
||||
}
|
||||
|
||||
@@ -305,6 +305,271 @@ describe('ProofreadRulesManager', () => {
|
||||
expect(screen.queryByText('deleted-pattern')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a disabled book rule visible so it can be re-enabled', async () => {
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } },
|
||||
});
|
||||
|
||||
const disabledRule: ProofreadRule = {
|
||||
id: 'd1',
|
||||
scope: 'book',
|
||||
pattern: 'disabled-pattern',
|
||||
replacement: 'x',
|
||||
enabled: false,
|
||||
isRegex: false,
|
||||
caseSensitive: true,
|
||||
order: 1,
|
||||
wholeWord: true,
|
||||
};
|
||||
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({
|
||||
viewStates: { book1: { viewSettings: { proofreadRules: [disabledRule] } } },
|
||||
});
|
||||
(useBookDataStore.setState as unknown as (state: unknown) => void)({
|
||||
booksData: {
|
||||
book1: {
|
||||
id: 'book1',
|
||||
book: null,
|
||||
file: null,
|
||||
config: { viewSettings: { proofreadRules: [disabledRule] } },
|
||||
bookDoc: null,
|
||||
isFixedLayout: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
useSidebarStore.setState({ sideBarBookKey: 'book1' });
|
||||
|
||||
renderWithProviders(<ProofreadRulesManager />);
|
||||
await Promise.resolve();
|
||||
setProofreadRulesVisibility(true);
|
||||
|
||||
await screen.findByRole('dialog');
|
||||
expect(screen.getByText('disabled-pattern')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggles a rule on/off via its switch', async () => {
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } },
|
||||
});
|
||||
|
||||
const rule: ProofreadRule = {
|
||||
id: 'b1',
|
||||
scope: 'book',
|
||||
pattern: 'toggle-me',
|
||||
replacement: 'x',
|
||||
enabled: true,
|
||||
isRegex: false,
|
||||
caseSensitive: true,
|
||||
order: 1,
|
||||
wholeWord: true,
|
||||
};
|
||||
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({
|
||||
viewStates: { book1: { viewSettings: { proofreadRules: [rule] } } },
|
||||
});
|
||||
(useBookDataStore.setState as unknown as (state: unknown) => void)({
|
||||
booksData: {
|
||||
book1: {
|
||||
id: 'book1',
|
||||
book: null,
|
||||
file: null,
|
||||
config: { viewSettings: { proofreadRules: [rule] } },
|
||||
bookDoc: null,
|
||||
isFixedLayout: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
useSidebarStore.setState({ sideBarBookKey: 'book1' });
|
||||
|
||||
const toggleSpy = vi
|
||||
.spyOn(useProofreadStore.getState(), 'toggleRule')
|
||||
.mockResolvedValue(undefined);
|
||||
const recreateSpy = vi
|
||||
.spyOn(useReaderStore.getState(), 'recreateViewer')
|
||||
.mockResolvedValue(undefined as never);
|
||||
|
||||
renderWithProviders(<ProofreadRulesManager />);
|
||||
await Promise.resolve();
|
||||
setProofreadRulesVisibility(true);
|
||||
await screen.findByRole('dialog');
|
||||
|
||||
const row = screen.getByText('toggle-me').closest('li');
|
||||
const toggle = within(row!).getByLabelText('Disable rule');
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(toggleSpy).toHaveBeenCalledWith(expect.anything(), 'book1', 'b1');
|
||||
expect(recreateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('edits a book rule Find pattern and saves via updateRule', async () => {
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } },
|
||||
});
|
||||
|
||||
const rule: ProofreadRule = {
|
||||
id: 'b1',
|
||||
scope: 'book',
|
||||
pattern: 'old-find',
|
||||
replacement: 'r',
|
||||
enabled: true,
|
||||
isRegex: false,
|
||||
caseSensitive: true,
|
||||
order: 1,
|
||||
wholeWord: true,
|
||||
};
|
||||
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({
|
||||
viewStates: { book1: { viewSettings: { proofreadRules: [rule] } } },
|
||||
});
|
||||
(useBookDataStore.setState as unknown as (state: unknown) => void)({
|
||||
booksData: {
|
||||
book1: {
|
||||
id: 'book1',
|
||||
book: null,
|
||||
file: null,
|
||||
config: { viewSettings: { proofreadRules: [rule] } },
|
||||
bookDoc: null,
|
||||
isFixedLayout: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
useSidebarStore.setState({ sideBarBookKey: 'book1' });
|
||||
|
||||
const updateSpy = vi
|
||||
.spyOn(useProofreadStore.getState(), 'updateRule')
|
||||
.mockResolvedValue(undefined);
|
||||
vi.spyOn(useReaderStore.getState(), 'recreateViewer').mockResolvedValue(undefined as never);
|
||||
|
||||
renderWithProviders(<ProofreadRulesManager />);
|
||||
await Promise.resolve();
|
||||
setProofreadRulesVisibility(true);
|
||||
await screen.findByRole('dialog');
|
||||
|
||||
const row = screen.getByText('old-find').closest('li');
|
||||
fireEvent.click(within(row!).getByLabelText('Edit'));
|
||||
|
||||
const findInput = screen.getByDisplayValue('old-find') as HTMLInputElement;
|
||||
expect(findInput.disabled).toBe(false);
|
||||
fireEvent.change(findInput, { target: { value: 'new-find' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'book1',
|
||||
'b1',
|
||||
expect.objectContaining({ pattern: 'new-find' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Find read-only when editing a selection rule', async () => {
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } },
|
||||
});
|
||||
|
||||
const sel: ProofreadRule = {
|
||||
id: 's1',
|
||||
scope: 'selection',
|
||||
pattern: 'sel-text',
|
||||
replacement: 'r',
|
||||
enabled: true,
|
||||
isRegex: false,
|
||||
caseSensitive: true,
|
||||
order: 1,
|
||||
wholeWord: true,
|
||||
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
|
||||
sectionHref: 'chapter1.html',
|
||||
};
|
||||
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({
|
||||
viewStates: { book1: { viewSettings: { proofreadRules: [sel] } } },
|
||||
});
|
||||
(useBookDataStore.setState as unknown as (state: unknown) => void)({
|
||||
booksData: {
|
||||
book1: {
|
||||
id: 'book1',
|
||||
book: null,
|
||||
file: null,
|
||||
config: { viewSettings: { proofreadRules: [sel] } },
|
||||
bookDoc: null,
|
||||
isFixedLayout: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
useSidebarStore.setState({ sideBarBookKey: 'book1' });
|
||||
|
||||
renderWithProviders(<ProofreadRulesManager />);
|
||||
await Promise.resolve();
|
||||
setProofreadRulesVisibility(true);
|
||||
await screen.findByRole('dialog');
|
||||
|
||||
const row = screen.getByText('sel-text').closest('li');
|
||||
fireEvent.click(within(row!).getByLabelText('Edit'));
|
||||
|
||||
const findInput = screen.getByDisplayValue('sel-text') as HTMLInputElement;
|
||||
expect(findInput.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('warns and does not save when the edited regex is invalid', async () => {
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } },
|
||||
});
|
||||
|
||||
const rule: ProofreadRule = {
|
||||
id: 'b1',
|
||||
scope: 'book',
|
||||
pattern: 'valid',
|
||||
replacement: 'r',
|
||||
enabled: true,
|
||||
isRegex: true,
|
||||
caseSensitive: true,
|
||||
order: 1,
|
||||
wholeWord: true,
|
||||
};
|
||||
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({
|
||||
viewStates: { book1: { viewSettings: { proofreadRules: [rule] } } },
|
||||
});
|
||||
(useBookDataStore.setState as unknown as (state: unknown) => void)({
|
||||
booksData: {
|
||||
book1: {
|
||||
id: 'book1',
|
||||
book: null,
|
||||
file: null,
|
||||
config: { viewSettings: { proofreadRules: [rule] } },
|
||||
bookDoc: null,
|
||||
isFixedLayout: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
useSidebarStore.setState({ sideBarBookKey: 'book1' });
|
||||
|
||||
const updateSpy = vi
|
||||
.spyOn(useProofreadStore.getState(), 'updateRule')
|
||||
.mockResolvedValue(undefined);
|
||||
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
|
||||
|
||||
renderWithProviders(<ProofreadRulesManager />);
|
||||
await Promise.resolve();
|
||||
setProofreadRulesVisibility(true);
|
||||
await screen.findByRole('dialog');
|
||||
|
||||
const row = screen.getByText('valid').closest('li');
|
||||
fireEvent.click(within(row!).getByLabelText('Edit'));
|
||||
|
||||
const findInput = screen.getByDisplayValue('valid') as HTMLInputElement;
|
||||
fireEvent.change(findInput, { target: { value: '(' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
expect(dispatchSpy).toHaveBeenCalledWith('toast', expect.objectContaining({ type: 'warning' }));
|
||||
});
|
||||
|
||||
it('renders a drag handle for each reorderable rule', async () => {
|
||||
const selectionRule: ProofreadRule = {
|
||||
id: 's1',
|
||||
|
||||
@@ -68,12 +68,26 @@ const RuleItem: React.FC<{
|
||||
rule: ProofreadRule;
|
||||
scope: ProofreadScope;
|
||||
isEditing: boolean;
|
||||
editingData: { pattern: string; replacement: string; enabled: boolean };
|
||||
editingData: {
|
||||
pattern: string;
|
||||
replacement: string;
|
||||
enabled: boolean;
|
||||
isRegex: boolean;
|
||||
caseSensitive: boolean;
|
||||
};
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onToggle: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onEditChange: (field: 'replacement', value: string) => void;
|
||||
onEditChange: (
|
||||
patch: Partial<{
|
||||
pattern: string;
|
||||
replacement: string;
|
||||
isRegex: boolean;
|
||||
caseSensitive: boolean;
|
||||
}>,
|
||||
) => void;
|
||||
}> = ({
|
||||
rule,
|
||||
scope,
|
||||
@@ -81,6 +95,7 @@ const RuleItem: React.FC<{
|
||||
editingData,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onSave,
|
||||
onCancel,
|
||||
onEditChange,
|
||||
@@ -96,14 +111,19 @@ const RuleItem: React.FC<{
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
const isSelection = scope === 'selection';
|
||||
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>
|
||||
<label className='text-base-content/70 text-xs font-medium'>
|
||||
{isSelection ? _('Selected text:') : _('Find:')}
|
||||
</label>
|
||||
<input
|
||||
className='input input-sm bg-base-200 text-sm opacity-60'
|
||||
className={clsx('input input-sm text-sm', isSelection && 'bg-base-200 opacity-60')}
|
||||
value={editingData.pattern}
|
||||
disabled
|
||||
disabled={isSelection}
|
||||
spellCheck='false'
|
||||
onChange={isSelection ? undefined : (e) => onEditChange({ pattern: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -112,10 +132,34 @@ const RuleItem: React.FC<{
|
||||
<input
|
||||
className='input input-sm text-sm'
|
||||
value={editingData.replacement}
|
||||
onChange={(e) => onEditChange('replacement', e.target.value)}
|
||||
spellCheck='false'
|
||||
onChange={(e) => onEditChange({ replacement: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isSelection && (
|
||||
<div className='flex flex-wrap items-center gap-x-5 gap-y-3'>
|
||||
<label className='flex cursor-pointer items-center gap-2'>
|
||||
<span className='text-base-content/70 text-sm'>{_('Regex:')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle toggle-sm'
|
||||
checked={editingData.isRegex}
|
||||
onChange={(e) => onEditChange({ isRegex: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
<label className='flex cursor-pointer items-center gap-2'>
|
||||
<span className='text-base-content/70 text-sm'>{_('Case sensitive:')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle toggle-sm'
|
||||
checked={editingData.caseSensitive}
|
||||
onChange={(e) => onEditChange({ caseSensitive: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-1 flex gap-2'>
|
||||
<button className='btn btn-primary btn-sm flex-1' onClick={onSave}>
|
||||
{_('Save')}
|
||||
@@ -130,8 +174,13 @@ const RuleItem: React.FC<{
|
||||
|
||||
return (
|
||||
<div className='relative 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 pe-20 text-base font-medium leading-snug'>{rule.pattern}</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex min-w-0 flex-1 flex-col gap-1.5',
|
||||
rule.enabled === false && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<div className='break-words pe-28 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:')}
|
||||
@@ -169,6 +218,13 @@ const RuleItem: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute right-2 top-2 flex items-center gap-1'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle toggle-sm'
|
||||
checked={rule.enabled !== false}
|
||||
onChange={onToggle}
|
||||
aria-label={rule.enabled !== false ? _('Disable rule') : _('Enable rule')}
|
||||
/>
|
||||
<button
|
||||
className='btn btn-ghost btn-sm h-8 w-8 p-0'
|
||||
onClick={onEdit}
|
||||
@@ -261,21 +317,17 @@ const useReplacementRules = (bookKey: string | null) => {
|
||||
(r: ProofreadRule) => !r.deletedAt,
|
||||
);
|
||||
|
||||
// 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),
|
||||
);
|
||||
|
||||
// Merge book-scoped rules with global (library) rules. Keep disabled rules so
|
||||
// the per-rule enable/disable toggle can turn them back on; book and library
|
||||
// ids never collide (ensureRuleId folds scope into the id), so dedup-by-id
|
||||
// here only guards a legacy shared id from listing the same rule twice.
|
||||
// Sort by `order` so a drag-to-reorder (which rewrites the order field)
|
||||
// persists visually; the stable sort keeps insertion order while every
|
||||
// rule still shares the default order.
|
||||
const mergedBookRules = validBookRules
|
||||
// persists visually; the stable sort keeps insertion order while every rule
|
||||
// still shares the default order.
|
||||
const mergedBookRules = bookScopedRules
|
||||
.concat(
|
||||
globalRules.filter(
|
||||
(gr: ProofreadRule) => !validBookRules.find((br: ProofreadRule) => br.id === gr.id),
|
||||
(gr: ProofreadRule) => !bookScopedRules.find((br: ProofreadRule) => br.id === gr.id),
|
||||
),
|
||||
)
|
||||
.sort(byOrder);
|
||||
@@ -288,7 +340,7 @@ export const ProofreadRulesManager: React.FC = () => {
|
||||
const { envConfig } = useEnv();
|
||||
const { recreateViewer } = useReaderStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { addRule, updateRule, removeRule, reorderRules } = useProofreadStore();
|
||||
const { addRule, updateRule, removeRule, reorderRules, toggleRule } = useProofreadStore();
|
||||
|
||||
// dnd-kit sensors mirror the dictionaries reorder list: a small pointer
|
||||
// distance gate avoids hijacking handle clicks, a touch delay gives mobile
|
||||
@@ -311,8 +363,19 @@ export const ProofreadRulesManager: React.FC = () => {
|
||||
pattern: string;
|
||||
replacement: string;
|
||||
enabled: boolean;
|
||||
isRegex: boolean;
|
||||
caseSensitive: boolean;
|
||||
onlyForTTS: boolean;
|
||||
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true, onlyForTTS: false });
|
||||
}>({
|
||||
id: null,
|
||||
scope: null,
|
||||
pattern: '',
|
||||
replacement: '',
|
||||
enabled: true,
|
||||
isRegex: false,
|
||||
caseSensitive: true,
|
||||
onlyForTTS: false,
|
||||
});
|
||||
|
||||
const { singleRules, bookRules } = useReplacementRules(sideBarBookKey);
|
||||
|
||||
@@ -331,6 +394,8 @@ export const ProofreadRulesManager: React.FC = () => {
|
||||
pattern: rule.pattern,
|
||||
replacement: rule.replacement,
|
||||
enabled: !!rule.enabled,
|
||||
isRegex: !!rule.isRegex,
|
||||
caseSensitive: rule.caseSensitive !== false,
|
||||
onlyForTTS: !!rule.onlyForTTS,
|
||||
});
|
||||
};
|
||||
@@ -342,6 +407,8 @@ export const ProofreadRulesManager: React.FC = () => {
|
||||
pattern: '',
|
||||
replacement: '',
|
||||
enabled: true,
|
||||
isRegex: false,
|
||||
caseSensitive: true,
|
||||
onlyForTTS: false,
|
||||
});
|
||||
};
|
||||
@@ -349,10 +416,28 @@ export const ProofreadRulesManager: React.FC = () => {
|
||||
const saveEdit = async () => {
|
||||
if (!editing.id || !editing.scope || !sideBarBookKey) return;
|
||||
|
||||
// Selection rules keep a read-only Find (anchored to the selected text), so
|
||||
// only validate the pattern when the user can actually edit it.
|
||||
const isSelection = editing.scope === 'selection';
|
||||
const pattern = isSelection ? editing.pattern : editing.pattern.trim();
|
||||
if (!isSelection) {
|
||||
const validation = validateReplacementRulePattern(pattern, editing.isRegex);
|
||||
if (!validation.valid) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'warning',
|
||||
message: pattern ? _('Invalid regular expression') : _('Find pattern cannot be empty'),
|
||||
timeout: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateRule(envConfig, sideBarBookKey, editing.id, {
|
||||
scope: editing.scope,
|
||||
pattern: editing.pattern,
|
||||
pattern,
|
||||
replacement: editing.replacement,
|
||||
isRegex: editing.isRegex,
|
||||
caseSensitive: editing.caseSensitive,
|
||||
enabled: editing.enabled,
|
||||
onlyForTTS: editing.onlyForTTS,
|
||||
});
|
||||
@@ -372,6 +457,14 @@ export const ProofreadRulesManager: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (rule: ProofreadRule) => {
|
||||
if (!sideBarBookKey) return;
|
||||
await toggleRule(envConfig, sideBarBookKey, rule.id);
|
||||
if (!rule.onlyForTTS) {
|
||||
recreateViewer(envConfig, sideBarBookKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRule = async () => {
|
||||
if (!sideBarBookKey) return;
|
||||
const pattern = addPattern.trim();
|
||||
@@ -447,9 +540,10 @@ export const ProofreadRulesManager: React.FC = () => {
|
||||
editingData={editing}
|
||||
onEdit={() => startEdit(rule)}
|
||||
onDelete={() => deleteRule(rule)}
|
||||
onToggle={() => handleToggle(rule)}
|
||||
onSave={saveEdit}
|
||||
onCancel={cancelEdit}
|
||||
onEditChange={(_, value) => setEditing({ ...editing, replacement: value })}
|
||||
onEditChange={(patch) => setEditing((prev) => ({ ...prev, ...patch }))}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user