diff --git a/lib/utils/text/text_manipulation.dart b/lib/utils/text/text_manipulation.dart index 067002d40..f50682270 100644 --- a/lib/utils/text/text_manipulation.dart +++ b/lib/utils/text/text_manipulation.dart @@ -60,6 +60,14 @@ String decodeHtmlEntities(String text) { }); } +/// ממיר ישויות רווח לצורה שההדגשה מזהה, בלי לשנות תגיות או ישויות תוכן. +String normalizeHtmlWhitespaceEntities(String text) { + return text.replaceAll( + RegExp(r'&(nbsp|thinsp|ensp|emsp);', caseSensitive: false), + ' ', + ); +} + /// רגקס להסרת ניקוד וטעמים. final RegExp _vowelsAndCantillation = RegExp(r'[֑-ׇ]'); diff --git a/lib/widgets/commentary/links_list_view.dart b/lib/widgets/commentary/links_list_view.dart index 1c25e30d7..9af92cd6c 100644 --- a/lib/widgets/commentary/links_list_view.dart +++ b/lib/widgets/commentary/links_list_view.dart @@ -12,6 +12,7 @@ import 'package:otzaria/services/commentary_service.dart'; import 'package:otzaria/services/target_line_links_service.dart'; import 'package:otzaria/settings/settings_exports.dart'; import 'package:otzaria/tabs/models/tab.dart'; +import 'package:otzaria/text_book/utils/commentary_search_utils.dart'; import 'package:otzaria/text_book/utils/link_anchor_markers.dart'; import 'package:otzaria/tools/dictionary/widgets/laaz_commentary_subblock.dart'; import 'package:otzaria/widgets/feedback/app_future_builder.dart'; @@ -44,17 +45,12 @@ RenderSettings buildSelectedLinkRenderSettings({ fontWeight: settingsState.commentatorsFontBold ? FontWeight.bold : null, lineHeight: settingsState.lineHeight, justifyText: true, + // חייב להתאים ל-partialWordMatch של הסינון, אחרת קישור נכנס לרשימה + // בלי שההתאמה שהכניסה אותו מודגשת בו. + partialWordHighlight: true, ); } -@visibleForTesting -String normalizeSelectedLinkText(String text) { - return text - .replaceAll(' ', ' ') - .replaceAll(RegExp(r'[^\S\r\n]+'), ' ') - .trim(); -} - @visibleForTesting String buildSelectedLinkContentKey(Link link) { // זהות היעד (אישי/רשמי+קטגוריה) נכללת כדי שלא יתערבב תוכן בין שני קישורים @@ -689,30 +685,37 @@ class _LinksListViewState extends State { // חיפוש בתוכן אם הופעל if (_searchInContent) { + String content; try { - final content = await link.content; - final cleanContent = normalizeSelectedLinkText( - utils.stripHtmlIfNeeded(content), - ).toLowerCase(); - if (cleanContent.contains(query)) { - filteredLinks.add(link); - _linksWithSearchResults.add(instanceKey); // מסמן שיש תוצאות בתוכן - _contentCache[contentKey] = link.content; // טוען את התוכן למטמון - - // פותח אוטומטית את הקישור הראשון עם תוצאות - if (_linksWithSearchResults.length == 1) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _expanded[instanceKey] = true; - }); - } - }); - } - } + content = await link.content; } catch (_) { - // אם יש שגיאה בטעינת התוכן, מוסיף בכל זאת אם מתאים לכותרת - // (כבר בדקנו את זה למעלה) + content = ''; + } + // אותו מונה של חיפוש המפרשים: מתעלם מניקוד ומפיסוק כמו כל משטחי + // החיפוש, ותואם את ההדגשה ש-SmartTextWidget מרנדר על אותו תוכן. + // הפענוח קודם לספירה — ` ` בין מילים היה חוסם ביטוי שהמשתמש רואה + // כרווח רגיל. + final matches = countCommentarySearchMatches( + content: utils.stripHtmlIfNeeded(content), + query: _searchQuery, + displayProfile: widget.displayProfile, + partialWordMatch: true, + ); + if (matches > 0) { + filteredLinks.add(link); + _linksWithSearchResults.add(instanceKey); // מסמן שיש תוצאות בתוכן + _contentCache[contentKey] = link.content; // טוען את התוכן למטמון + + // פותח אוטומטית את הקישור הראשון עם תוצאות + if (_linksWithSearchResults.length == 1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _expanded[instanceKey] = true; + }); + } + }); + } } } } @@ -1001,7 +1004,7 @@ class _LinksListViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ SmartTextWidget( - text: content, + text: utils.normalizeHtmlWhitespaceEntities(content), settings: buildSelectedLinkRenderSettings( settingsState: settingsState, displayProfile: widget.displayProfile, diff --git a/test/text_book/view/selected_line_links_view_test.dart b/test/text_book/view/selected_line_links_view_test.dart index 347b69939..b6413de90 100644 --- a/test/text_book/view/selected_line_links_view_test.dart +++ b/test/text_book/view/selected_line_links_view_test.dart @@ -59,22 +59,6 @@ void main() { }); }); - group('normalizeSelectedLinkText', () { - test('collapses tabs into a single space', () { - expect( - normalizeSelectedLinkText('ודר\t\t\tשאל'), - 'ודר שאל', - ); - }); - - test('collapses nbsp and repeated spaces', () { - expect( - normalizeSelectedLinkText('ודר  שאל'), - 'ודר שאל', - ); - }); - }); - group('buildSelectedLinksSearchKey', () { test( 'changes when the links change even if the list length stays the same', diff --git a/test/widgets/commentary/links_content_search_nikud_test.dart b/test/widgets/commentary/links_content_search_nikud_test.dart new file mode 100644 index 000000000..515b871dc --- /dev/null +++ b/test/widgets/commentary/links_content_search_nikud_test.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:otzaria/models/link_types.dart'; +import 'package:otzaria/models/links.dart'; +import 'package:otzaria/settings/engine/settings_bloc.dart'; +import 'package:otzaria/settings/engine/settings_event.dart'; +import 'package:otzaria/settings/engine/settings_state.dart'; +import 'package:otzaria/text_display/models/text_display_profile.dart'; +import 'package:otzaria/widgets/commentary/links_list_view.dart'; +import 'package:otzaria/widgets/smart_text/smart_text_widget.dart'; +import 'package:otzaria/widgets/smart_text/text_renderer_service.dart'; +import 'package:otzaria/widgets/text/rtl_text_field.dart'; + +import '../../helpers/memory_settings_cache.dart'; + +/// תוכן הקישורים הוא הטקסט המנוקד ביותר בספרייה (פסוקים, משניות, ציטוטים). +/// הסינון חייב להתעלם מניקוד כמו כל שאר משטחי החיפוש באפליקציה. +class _ContentLink extends Link { + _ContentLink({ + required super.heRef, + required super.index1, + required super.path2, + required super.index2, + required super.connectionType, + required this._content, + }); + + final String _content; + + @override + Future get content => Future.value(_content); + + @override + Future get displayReference => Future.value(fallbackDisplayReference); +} + +class _FakeSettingsBloc extends Bloc + implements SettingsBloc { + _FakeSettingsBloc() : super(SettingsState.initial()) { + on((_, _) {}); + } + + @override + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); +} + +Future _pump(WidgetTester tester, List links) async { + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: _FakeSettingsBloc(), + child: Scaffold( + body: LinksListView( + displayProfile: TextDisplayProfile.defaults, + links: links, + chipSourceLinks: links, + openBookTitle: 'שבת', + selectedLinkTypes: const {}, + onSelectedLinkTypesChanged: (_) {}, + openBookCallback: (_) {}, + fontSize: 16, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +/// מקליד שאילתה ומסמן את "חפש גם בתוכן הקישורים". +Future _searchInContent(WidgetTester tester, String query) async { + await tester.enterText(find.byType(RtlTextField), query); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); +} + +void main() { + setUpAll(() async { + WidgetsFlutterBinding.ensureInitialized(); + await Settings.init(cacheProvider: MemorySettingsCache()); + }); + + group('חיפוש בתוכן הקישורים — ניקוד (issue #1114)', () { + testWidgets('שאילתה ללא ניקוד מוצאת תוכן מנוקד', (tester) async { + final links = [ + _ContentLink( + heRef: 'הפניה לפסוק', + index1: 5, + path2: 'בראשית', + index2: 1, + connectionType: LinkTypes.quotation, + content: 'בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ', + ), + ]; + + await _pump(tester, links); + await _searchInContent(tester, 'אלהים'); + + expect( + find.text('לא נמצאו קישורים התואמים לחיפוש'), + findsNothing, + reason: 'המילה קיימת בתוכן, רק מנוקדת', + ); + }); + + testWidgets('שאילתה מוצאת תוכן ללא ניקוד (בקרה)', (tester) async { + final links = [ + _ContentLink( + heRef: 'הפניה לפסוק', + index1: 5, + path2: 'בראשית', + index2: 1, + connectionType: LinkTypes.quotation, + content: 'בראשית ברא אלהים את השמים ואת הארץ', + ), + ]; + + await _pump(tester, links); + await _searchInContent(tester, 'אלהים'); + + expect(find.text('לא נמצאו קישורים התואמים לחיפוש'), findsNothing); + }); + }); + + group('חיפוש בתוכן הקישורים — HTML', () { + testWidgets('ישות HTML בין המילים אינה חוסמת ביטוי', (tester) async { + final links = [ + _ContentLink( + heRef: 'הפניה', + index1: 5, + path2: 'ספר', + index2: 1, + connectionType: LinkTypes.quotation, + content: 'ודר שאל בעניין זה', + ), + ]; + + await _pump(tester, links); + await _searchInContent(tester, 'ודר שאל'); + + expect(find.text('לא נמצאו קישורים התואמים לחיפוש'), findsNothing); + await tester.tap(find.byType(ExpansionTile)); + await tester.pumpAndSettle(); + final renderedLink = tester.widget( + find.byType(SmartTextWidget), + ); + final highlighted = TextRendererService.processText( + renderedLink.text, + renderedLink.settings, + ); + expect(highlighted, contains('ודר')); + expect(highlighted, contains('שאל')); + }); + + testWidgets('תגית inline בתוך הביטוי אינה חוסמת אותו', (tester) async { + final links = [ + _ContentLink( + heRef: 'הפניה', + index1: 5, + path2: 'ספר', + index2: 1, + connectionType: LinkTypes.quotation, + content: 'אמר רבי יוסי', + ), + ]; + + await _pump(tester, links); + await _searchInContent(tester, 'אמר רבי'); + + expect(find.text('לא נמצאו קישורים התואמים לחיפוש'), findsNothing); + }); + }); +}