Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/utils/text/text_manipulation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'[֑-ׇ]');

Expand Down
65 changes: 34 additions & 31 deletions lib/widgets/commentary/links_list_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
// זהות היעד (אישי/רשמי+קטגוריה) נכללת כדי שלא יתערבב תוכן בין שני קישורים
Expand Down Expand Up @@ -689,30 +685,37 @@ class _LinksListViewState extends State<LinksListView> {

// חיפוש בתוכן אם הופעל
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 מרנדר על אותו תוכן.
// הפענוח קודם לספירה — `&nbsp;` בין מילים היה חוסם ביטוי שהמשתמש רואה
// כרווח רגיל.
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;
});
}
});
}
}
}
}
Expand Down Expand Up @@ -1001,7 +1004,7 @@ class _LinksListViewState extends State<LinksListView> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SmartTextWidget(
text: content,
text: utils.normalizeHtmlWhitespaceEntities(content),
settings: buildSelectedLinkRenderSettings(
settingsState: settingsState,
displayProfile: widget.displayProfile,
Expand Down
16 changes: 0 additions & 16 deletions test/text_book/view/selected_line_links_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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('ודר&nbsp; שאל'),
'ודר שאל',
);
});
});

group('buildSelectedLinksSearchKey', () {
test(
'changes when the links change even if the list length stays the same',
Expand Down
176 changes: 176 additions & 0 deletions test/widgets/commentary/links_content_search_nikud_test.dart
Original file line number Diff line number Diff line change
@@ -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<String> get content => Future.value(_content);

@override
Future<String> get displayReference => Future.value(fallbackDisplayReference);
}

class _FakeSettingsBloc extends Bloc<SettingsEvent, SettingsState>
implements SettingsBloc {
_FakeSettingsBloc() : super(SettingsState.initial()) {
on<SettingsEvent>((_, _) {});
}

@override
dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i);
}

Future<void> _pump(WidgetTester tester, List<Link> links) async {
await tester.pumpWidget(
MaterialApp(
home: BlocProvider<SettingsBloc>.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<void> _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: 'ודר&nbsp;שאל בעניין זה',
),
];

await _pump(tester, links);
await _searchInContent(tester, 'ודר שאל');

expect(find.text('לא נמצאו קישורים התואמים לחיפוש'), findsNothing);
await tester.tap(find.byType(ExpansionTile));
await tester.pumpAndSettle();
final renderedLink = tester.widget<SmartTextWidget>(
find.byType(SmartTextWidget),
);
final highlighted = TextRendererService.processText(
renderedLink.text,
renderedLink.settings,
);
expect(highlighted, contains('<span style="color: red">ודר</span>'));
expect(highlighted, contains('<span style="color: red">שאל</span>'));
});

testWidgets('תגית inline בתוך הביטוי אינה חוסמת אותו', (tester) async {
final links = [
_ContentLink(
heRef: 'הפניה',
index1: 5,
path2: 'ספר',
index2: 1,
connectionType: LinkTypes.quotation,
content: 'אמר <b>רבי</b> יוסי',
),
];

await _pump(tester, links);
await _searchInContent(tester, 'אמר רבי');

expect(find.text('לא נמצאו קישורים התואמים לחיפוש'), findsNothing);
});
});
}