Skip to content

Commit c370ea9

Browse files
committed
convert search perspective to modal dialog, fixes #6125
1 parent fd34192 commit c370ea9

32 files changed

Lines changed: 2800 additions & 1138 deletions

core/src/main/java/org/apache/hop/core/Const.java

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -991,13 +991,6 @@ public static boolean toBoolean(String string) {
991991
"Name of the variable to set so that Nulls are considered while parsing JSON files. If HOP_JSON_INPUT_INCLUDE_NULLS is \"Y\" then nulls will be included (default behavior) otherwise they will not be included")
992992
public static final String HOP_JSON_INPUT_INCLUDE_NULLS = "HOP_JSON_INPUT_INCLUDE_NULLS";
993993

994-
/** This variable is used to disable the strict searching of the context dialog */
995-
@Variable(
996-
value = "N",
997-
description =
998-
"This variable influences how the search is done in the context dialog, when set to Y it will do a strict search (Needed for automated UI testing)")
999-
public static final String HOP_CONTEXT_DIALOG_STRICT_SEARCH = "HOP_CONTEXT_DIALOG_STRICT_SEARCH";
1000-
1001994
/** By default, HOP do consider NULLs while parsing input */
1002995
public static final String JSON_INPUT_INCLUDE_NULLS = "Y";
1003996

core/src/main/java/org/apache/hop/core/gui/plugin/action/GuiAction.java

Lines changed: 21 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,8 @@
2020
import java.util.ArrayList;
2121
import java.util.List;
2222
import java.util.Objects;
23-
import org.apache.commons.lang3.StringUtils;
24-
import org.apache.hop.core.Const;
2523
import org.apache.hop.core.gui.plugin.IGuiActionLambda;
26-
import org.apache.hop.core.util.EnvUtil;
27-
import org.apache.hop.core.util.StringUtil;
24+
import org.apache.hop.core.search.SearchMatcher;
2825

2926
public class GuiAction {
3027
private String id;
@@ -118,58 +115,29 @@ public GuiAction(GuiAction guiAction) {
118115
this.classLoader = guiAction.getClassLoader();
119116
}
120117

121-
public boolean containsFilterStrings(String[] filters) {
122-
for (String filter : filters) {
123-
if (!containsFilterString(filter)) {
124-
return false;
125-
}
126-
}
127-
return true;
128-
}
129-
130-
public boolean containsFilterString(String filter) {
131-
if (filter == null) {
132-
return false;
133-
}
134-
135-
filter = StringUtil.removeDiacriticalMarks(filter);
136-
137-
if (matchesString(name, filter)) {
138-
return true;
139-
}
140-
if (matchesString(tooltip, filter)) {
141-
return true;
142-
}
143-
if (type != null && matchesString(type.name(), filter)) {
144-
return true;
118+
/**
119+
* Score this action against a prepared matcher. Used to filter and rank actions (e.g. in the
120+
* context dialog). Returns the best score across the action's name, tooltip, type, keywords and
121+
* category; {@code 0} means no match.
122+
*
123+
* @param matcher the shared search matcher built from the user's filter text
124+
* @return the best relevance score in {@code [0,1]}
125+
*/
126+
public double matchScore(SearchMatcher matcher) {
127+
// Weight the fields so a hit on the name ranks above a hit on a tooltip/type, which in turn
128+
// ranks above a hit on a keyword or category. This keeps the most relevant actions (those whose
129+
// name matches) at the top and stops loosely-related keyword/category hits from crowding in.
130+
double best = matcher.score(name);
131+
best = Math.max(best, 0.9 * matcher.score(tooltip));
132+
if (type != null) {
133+
best = Math.max(best, 0.9 * matcher.score(type.name()));
145134
}
146-
for (String keyword : keywords) {
147-
if (matchesString(keyword, filter)) {
148-
return true;
135+
if (keywords != null) {
136+
for (String keyword : keywords) {
137+
best = Math.max(best, 0.8 * matcher.score(keyword));
149138
}
150139
}
151-
if (matchesString(category, filter)) {
152-
return true;
153-
}
154-
return false;
155-
}
156-
157-
private boolean matchesString(String string, String filter) {
158-
if (StringUtils.isEmpty(string)) {
159-
return false;
160-
}
161-
162-
string = StringUtil.removeDiacriticalMarks(string);
163-
164-
if (EnvUtil.getSystemProperty(Const.HOP_CONTEXT_DIALOG_STRICT_SEARCH, "N")
165-
.equalsIgnoreCase("Y")) {
166-
// Use strict matching of strings
167-
return string.equalsIgnoreCase(filter);
168-
} else {
169-
// TODO: consider some fuzzy matching algorithm
170-
// TODO: Do a Levenshtein distance on the filter string across all valid string indexes 0..
171-
return string.toUpperCase().contains(filter.toUpperCase());
172-
}
140+
return Math.max(best, 0.7 * matcher.score(category));
173141
}
174142

175143
@Override
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.hop.core.search;
19+
20+
import java.util.Locale;
21+
import java.util.regex.Pattern;
22+
import org.apache.commons.lang3.StringUtils;
23+
import org.apache.hop.core.util.HopJaroWinklerDistance;
24+
import org.apache.hop.core.util.StringUtil;
25+
26+
/**
27+
* A single, reusable string matcher shared by every search box in Hop (global search, file explorer
28+
* filter, settings filter, the transform/action picker, ...). It is built once from a query and
29+
* then scores any number of candidate strings.
30+
*
31+
* <p>Matching is, in order of decreasing score:
32+
*
33+
* <ol>
34+
* <li>exact (the whole normalized target equals the term),
35+
* <li>word-prefix (a word in the target starts with the term),
36+
* <li>substring (the term appears somewhere in the target),
37+
* <li>fuzzy (Jaro-Winkler similarity above a threshold) - only when fuzzy matching is enabled.
38+
* </ol>
39+
*
40+
* <p>The query is normalized (diacritics removed, optionally lower-cased, whitespace collapsed) and
41+
* split into terms on whitespace and commas; <b>all</b> terms must match (AND). Alternatively a
42+
* regular-expression mode does a partial regex match.
43+
*
44+
* <p>{@link #score(String)} returns a value in {@code [0,1]} ({@code 0} means "no match") which
45+
* callers can use to rank results; {@link #matches(String)} is the boolean shortcut.
46+
*/
47+
public class SearchMatcher {
48+
49+
/** Default Jaro-Winkler similarity above which a fuzzy match is accepted. */
50+
public static final double DEFAULT_FUZZY_THRESHOLD = 0.86;
51+
52+
/** Fuzzy matching is only attempted for terms at least this long (avoids noise on tiny terms). */
53+
private static final int MIN_FUZZY_TERM_LENGTH = 3;
54+
55+
/**
56+
* A fuzzy match is only considered when the shorter of the two strings is at least this fraction
57+
* of the longer one. Without this a long query (e.g. "tableinput") would fuzzily latch onto a
58+
* much shorter candidate (e.g. the keyword "table") thanks to the Jaro-Winkler common-prefix
59+
* bonus, which is too lenient: a genuine typo is roughly the same length as its target.
60+
*/
61+
private static final double MIN_FUZZY_LENGTH_RATIO = 0.6;
62+
63+
private static final double SCORE_EXACT = 1.0;
64+
private static final double SCORE_WORD = 0.97;
65+
private static final double SCORE_PREFIX = 0.92;
66+
private static final double SCORE_SUBSTRING = 0.8;
67+
private static final double FUZZY_WEIGHT = 0.7;
68+
69+
private final boolean caseSensitive;
70+
private final boolean regex;
71+
private final boolean fuzzy;
72+
private final double fuzzyThreshold;
73+
private final String[] terms;
74+
private final Pattern pattern;
75+
76+
public SearchMatcher(String query, boolean caseSensitive, boolean regEx, boolean fuzzy) {
77+
this(query, caseSensitive, regEx, fuzzy, DEFAULT_FUZZY_THRESHOLD);
78+
}
79+
80+
public SearchMatcher(
81+
String query, boolean caseSensitive, boolean regEx, boolean fuzzy, double fuzzyThreshold) {
82+
this.caseSensitive = caseSensitive;
83+
this.regex = regEx;
84+
this.fuzzy = fuzzy && !regEx;
85+
this.fuzzyThreshold = fuzzyThreshold;
86+
87+
if (regEx) {
88+
Pattern compiled = null;
89+
if (StringUtils.isNotEmpty(query)) {
90+
try {
91+
compiled = Pattern.compile(query, caseSensitive ? 0 : Pattern.CASE_INSENSITIVE);
92+
} catch (Exception e) {
93+
// An incomplete/invalid expression simply matches nothing instead of throwing.
94+
compiled = null;
95+
}
96+
}
97+
this.pattern = compiled;
98+
this.terms = new String[0];
99+
} else {
100+
this.pattern = null;
101+
String normalized = normalize(query, caseSensitive);
102+
this.terms = normalized.isEmpty() ? new String[0] : normalized.split(" ");
103+
}
104+
}
105+
106+
/**
107+
* Normalize a string for matching: remove diacritical marks, optionally lower-case, turn commas
108+
* into spaces and collapse runs of whitespace.
109+
*/
110+
public static String normalize(String string, boolean caseSensitive) {
111+
if (StringUtils.isEmpty(string)) {
112+
return "";
113+
}
114+
String normalized = StringUtil.removeDiacriticalMarks(string);
115+
if (!caseSensitive) {
116+
normalized = normalized.toLowerCase(Locale.ROOT);
117+
}
118+
return normalized.replace(',', ' ').replaceAll("\\s+", " ").trim();
119+
}
120+
121+
/** Whether the given target matches at all. */
122+
public boolean matches(String target) {
123+
return score(target) > 0.0;
124+
}
125+
126+
/**
127+
* Score a candidate string against the query.
128+
*
129+
* @param target the candidate
130+
* @return a relevance score in {@code [0,1]}; {@code 0} means no match
131+
*/
132+
public double score(String target) {
133+
// Regular expression mode: a partial match counts. An invalid/empty pattern matches nothing.
134+
if (regex) {
135+
return pattern != null && target != null && pattern.matcher(target).find() ? 1.0 : 0.0;
136+
}
137+
138+
// An empty query matches everything non-empty (keeps the old "match all" behavior).
139+
if (terms.length == 0) {
140+
return StringUtils.isNotEmpty(target) ? 1.0 : 0.0;
141+
}
142+
if (StringUtils.isEmpty(target)) {
143+
return 0.0;
144+
}
145+
146+
String normalizedTarget = normalize(target, caseSensitive);
147+
String[] words = normalizedTarget.split(" ");
148+
149+
double total = 0.0;
150+
for (String term : terms) {
151+
double termScore = scoreTerm(term, normalizedTarget, words);
152+
if (termScore <= 0.0) {
153+
// AND semantics: every term must match.
154+
return 0.0;
155+
}
156+
total += termScore;
157+
}
158+
return total / terms.length;
159+
}
160+
161+
private double scoreTerm(String term, String target, String[] words) {
162+
if (target.equals(term)) {
163+
return SCORE_EXACT;
164+
}
165+
if (target.contains(term)) {
166+
for (String word : words) {
167+
if (word.equals(term)) {
168+
return SCORE_WORD;
169+
}
170+
}
171+
for (String word : words) {
172+
if (word.startsWith(term)) {
173+
return SCORE_PREFIX;
174+
}
175+
}
176+
return SCORE_SUBSTRING;
177+
}
178+
if (fuzzy && term.length() >= MIN_FUZZY_TERM_LENGTH) {
179+
double best = similarity(term, target);
180+
for (String word : words) {
181+
best = Math.max(best, similarity(term, word));
182+
}
183+
if (best >= fuzzyThreshold) {
184+
return best * FUZZY_WEIGHT;
185+
}
186+
}
187+
return 0.0;
188+
}
189+
190+
/**
191+
* Jaro-Winkler similarity, but {@code 0} when the two strings differ too much in length to be a
192+
* plausible typo of one another (see {@link #MIN_FUZZY_LENGTH_RATIO}).
193+
*/
194+
private static double similarity(String a, String b) {
195+
if (StringUtils.isEmpty(a) || StringUtils.isEmpty(b)) {
196+
return 0.0;
197+
}
198+
int shorter = Math.min(a.length(), b.length());
199+
int longer = Math.max(a.length(), b.length());
200+
if ((double) shorter / longer < MIN_FUZZY_LENGTH_RATIO) {
201+
return 0.0;
202+
}
203+
HopJaroWinklerDistance distance = new HopJaroWinklerDistance();
204+
distance.apply(a, b);
205+
Double jw = distance.getJaroWinklerDistance();
206+
return jw == null ? 0.0 : jw;
207+
}
208+
}

core/src/main/java/org/apache/hop/core/search/SearchQuery.java

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@
1717

1818
package org.apache.hop.core.search;
1919

20-
import org.apache.commons.lang3.StringUtils;
21-
2220
public class SearchQuery implements ISearchQuery {
2321
private String searchString;
2422
private boolean caseSensitive;
2523
private boolean regEx;
2624

25+
/** Lazily built matcher, shared by all search boxes. Rebuilt when the query changes. */
26+
private SearchMatcher matcher;
27+
2728
public SearchQuery() {}
2829

2930
public SearchQuery(String searchString, boolean caseSensitive, boolean regEx) {
@@ -32,27 +33,18 @@ public SearchQuery(String searchString, boolean caseSensitive, boolean regEx) {
3233
this.regEx = regEx;
3334
}
3435

35-
@Override
36-
public boolean matches(String string) {
37-
if (StringUtils.isEmpty(searchString)) {
38-
// match everything non-null
39-
//
40-
return StringUtils.isNotEmpty(string);
36+
private SearchMatcher matcher() {
37+
if (matcher == null) {
38+
// Content matching: normalized + multi-term substring (or regex). No fuzzy matching here -
39+
// fuzzy is only meaningful for short names, not for long descriptions / settings values.
40+
matcher = new SearchMatcher(searchString, caseSensitive, regEx, false);
4141
}
42+
return matcher;
43+
}
4244

43-
if (regEx) {
44-
if (caseSensitive) {
45-
return string.matches(searchString);
46-
} else {
47-
return string.toLowerCase().matches(searchString.toLowerCase());
48-
}
49-
} else {
50-
if (caseSensitive) {
51-
return string.contains(searchString);
52-
} else {
53-
return string.toLowerCase().contains(searchString.toLowerCase());
54-
}
55-
}
45+
@Override
46+
public boolean matches(String string) {
47+
return matcher().matches(string);
5648
}
5749

5850
/**
@@ -70,6 +62,7 @@ public String getSearchString() {
7062
*/
7163
public void setSearchString(String searchString) {
7264
this.searchString = searchString;
65+
this.matcher = null;
7366
}
7467

7568
/**
@@ -87,6 +80,7 @@ public boolean isCaseSensitive() {
8780
*/
8881
public void setCaseSensitive(boolean caseSensitive) {
8982
this.caseSensitive = caseSensitive;
83+
this.matcher = null;
9084
}
9185

9286
/**
@@ -104,5 +98,6 @@ public boolean isRegEx() {
10498
*/
10599
public void setRegEx(boolean regEx) {
106100
this.regEx = regEx;
101+
this.matcher = null;
107102
}
108103
}

0 commit comments

Comments
 (0)