diff --git a/pontoon/base/forms.py b/pontoon/base/forms.py index 1b4e9fd7ee..8b7c69f360 100644 --- a/pontoon/base/forms.py +++ b/pontoon/base/forms.py @@ -300,6 +300,7 @@ class GetEntitiesForm(forms.Form): search_identifiers = forms.BooleanField(required=False) search_translations_only = forms.BooleanField(required=False) search_rejected_translations = forms.BooleanField(required=False) + search_match_case = forms.BooleanField(required=False) tag = forms.CharField(required=False) time = forms.CharField(required=False) author = forms.CharField(required=False) diff --git a/pontoon/base/models/entity.py b/pontoon/base/models/entity.py index ac0ef9af44..17c3f5049c 100644 --- a/pontoon/base/models/entity.py +++ b/pontoon/base/models/entity.py @@ -734,6 +734,7 @@ def for_project_locale( search_identifiers=None, search_translations_only=None, search_rejected_translations=None, + search_match_case=None, time=None, author=None, review_time=None, @@ -842,21 +843,29 @@ def for_project_locale( # only tag needs `distinct` as it traverses m2m fields entities = entities.distinct() - # TODO: Uncomment the following lines to reactivate the - # feature once all search options are implemented: - # - 857 (rejected translations) - # - 869-870 (context identifiers) - # - 883-884 (translations only) - # Filter by search parameters if search: search_list = utils.get_search_phrases(search) + # Include rejected translations q_rejected = ( - Q() - ) # if search_rejected_translations else Q(translation__rejected=False) + Q() if search_rejected_translations else Q(translation__rejected=False) + ) + + # Modify query based on case sensitivity filter + translation_case_lookup = ( + "contains" if search_match_case else "icontains_collate" + ) + translation_filters = ( - Q(translation__string__icontains_collate=(search, locale.db_collation)) + Q( + **{ + f"translation__string__{translation_case_lookup}": ( + search, + locale.db_collation, + ) + } + ) & Q(translation__locale=locale) & q_rejected for search in search_list @@ -866,22 +875,36 @@ def for_project_locale( "id", flat=True ) - # if not search_translations_only: - q_key = Q(key__icontains=search) # if search_identifiers else Q() - entity_filters = ( - Q(string__icontains=search) | Q(string_plural__icontains=search) | q_key - for search in search_list - ) + # Search in source strings + if not search_translations_only: + # Search in string (context) identifiers + + case_lookup = "contains" if search_match_case else "icontains" + q_key = Q() + # TODO: Uncomment the 5 lines below to reactivate the + # context identifiers filter once .ftl bug is fixed (issue #3284): + # q_key = ( + # Q(**{f"key__{case_lookup}": (search)}) + # if search_identifiers + # else Q() + # ) + + entity_filters = ( + Q(**{f"string__{case_lookup}": (search)}) + | Q(**{f"string_plural__{case_lookup}": (search)}) + | q_key + for search in search_list + ) - entity_matches = entities.filter(*entity_filters).values_list( - "id", flat=True - ) + entity_matches = entities.filter(*entity_filters).values_list( + "id", flat=True + ) - entities = Entity.objects.filter( - pk__in=set(list(translation_matches) + list(entity_matches)) - ) - # else: - # entities = Entity.objects.filter(pk__in=set(list(translation_matches))) + entities = Entity.objects.filter( + pk__in=set(list(translation_matches) + list(entity_matches)) + ) + else: + entities = Entity.objects.filter(pk__in=set(list(translation_matches))) order_fields = ("resource__order", "order") if project.slug == "all-projects": diff --git a/pontoon/base/views.py b/pontoon/base/views.py index 328ad66615..5440d6f217 100755 --- a/pontoon/base/views.py +++ b/pontoon/base/views.py @@ -248,6 +248,7 @@ def entities(request): "search_identifiers", "search_translations_only", "search_rejected_translations", + "search_match_case", "time", "author", "review_time", diff --git a/translate/src/api/entity.ts b/translate/src/api/entity.ts index d2b61830d8..ba00950795 100644 --- a/translate/src/api/entity.ts +++ b/translate/src/api/entity.ts @@ -127,6 +127,7 @@ function buildFetchPayload( 'search_identifiers', 'search_translations_only', 'search_rejected_translations', + 'search_match_case', 'extra', 'tag', 'author', diff --git a/translate/src/context/Location.tsx b/translate/src/context/Location.tsx index 8ea4232c39..b1f2d8a418 100644 --- a/translate/src/context/Location.tsx +++ b/translate/src/context/Location.tsx @@ -23,6 +23,7 @@ export type Location = { search_identifiers: boolean; search_translations_only: boolean; search_rejected_translations: boolean; + search_match_case: boolean; tag: string | null; author: string | null; time: string | null; @@ -39,6 +40,7 @@ const emptyParams = { search_identifiers: false, search_translations_only: false, search_rejected_translations: false, + search_match_case: false, tag: null, author: null, time: null, @@ -103,6 +105,7 @@ function parse( search_rejected_translations: params.has( 'search_rejected_translations', ), + search_match_case: params.has('search_match_case'), tag: params.get('tag'), author: params.get('author'), time: params.get('time'), @@ -136,6 +139,7 @@ function stringify(prev: Location, next: string | Partial) { 'search_identifiers', 'search_translations_only', 'search_rejected_translations', + 'search_match_case', 'tag', 'author', 'time', diff --git a/translate/src/modules/placeable/components/Highlight.tsx b/translate/src/modules/placeable/components/Highlight.tsx index 544784da3e..8b9fda65b4 100644 --- a/translate/src/modules/placeable/components/Highlight.tsx +++ b/translate/src/modules/placeable/components/Highlight.tsx @@ -1,7 +1,8 @@ import { Localized } from '@fluent/react'; import escapeRegExp from 'lodash.escaperegexp'; -import React from 'react'; +import React, { useContext } from 'react'; import { TermState } from '~/modules/terms'; +import { Location } from '~/context/Location'; import './Highlight.css'; import { ReactElement } from 'react'; @@ -63,6 +64,7 @@ export function Highlight({ length: number; mark: ReactElement; }> = []; + const location = useContext(Location); for (const match of source.matchAll(placeholder)) { let l10nId: string; @@ -171,10 +173,13 @@ export function Highlight({ if (term.startsWith('"') && term.length >= 3 && term.endsWith('"')) { term = term.slice(1, -1); } - let lcTerm = term.toLowerCase(); + const highlightTerm = location.search_match_case + ? term + : term.toLowerCase(); + const highlightSource = location.search_match_case ? source : lcSource; let pos = 0; let next: number; - while ((next = lcSource.indexOf(lcTerm, pos)) !== -1) { + while ((next = highlightSource.indexOf(highlightTerm, pos)) !== -1) { let i = marks.findIndex((m) => m.index + m.length > next); if (i === -1) { i = marks.length; diff --git a/translate/src/modules/search/components/SearchBox.tsx b/translate/src/modules/search/components/SearchBox.tsx index d619b82e22..a5c9cfadfc 100644 --- a/translate/src/modules/search/components/SearchBox.tsx +++ b/translate/src/modules/search/components/SearchBox.tsx @@ -44,7 +44,8 @@ export type FilterType = 'authors' | 'extras' | 'statuses' | 'tags'; export type SearchType = | 'search_identifiers' | 'search_translations_only' - | 'search_rejected_translations'; + | 'search_rejected_translations' + | 'search_match_case'; function getTimeRangeFromURL(timeParameter: string): TimeRangeType { const [from, to] = timeParameter.split('-'); @@ -67,6 +68,7 @@ export type SearchState = { search_identifiers: boolean; search_translations_only: boolean; search_rejected_translations: boolean; + search_match_case: boolean; }; export type SearchAction = { @@ -129,6 +131,7 @@ export function SearchBoxBase({ search_identifiers: false, search_translations_only: false, search_rejected_translations: false, + search_match_case: false, }, ); @@ -160,6 +163,7 @@ export function SearchBoxBase({ search_identifiers, search_translations_only, search_rejected_translations, + search_match_case, time, } = parameters; updateSearchOptions([ @@ -172,6 +176,10 @@ export function SearchBoxBase({ searchOption: 'search_rejected_translations', value: search_rejected_translations, }, + { + searchOption: 'search_match_case', + value: search_match_case, + }, ]); setTimeRange(time); }, [parameters]); @@ -253,6 +261,7 @@ export function SearchBoxBase({ search_identifiers, search_translations_only, search_rejected_translations, + search_match_case, } = searchOptions; dispatch(resetEntities()); parameters.push({ @@ -260,6 +269,7 @@ export function SearchBoxBase({ search_identifiers: search_identifiers, search_translations_only: search_translations_only, search_rejected_translations: search_rejected_translations, + search_match_case: search_match_case, entity: 0, // With the new results, the current entity might not be available anymore. }); }), diff --git a/translate/src/modules/search/components/SearchPanel.tsx b/translate/src/modules/search/components/SearchPanel.tsx index 06979af980..f4067c45cf 100644 --- a/translate/src/modules/search/components/SearchPanel.tsx +++ b/translate/src/modules/search/components/SearchPanel.tsx @@ -8,11 +8,6 @@ import { useOnDiscard } from '~/utils'; import './SearchPanel.css'; -// TODO: Remove the variable below to reactivate the feature once -// all search options are implemented -// Disable SearchPanel component until fully complete -const disable: Boolean = true; - type Props = { searchOptions: SearchState; applyOptions: () => void; @@ -123,16 +118,10 @@ export function SearchPanel({ return (
- {/* {TODO: Remove the style attribute for the div below} */} -
+
- {/* {TODO: Remove the second condition below} */} - {visible && !disable ? ( + {visible ? (