From 52542e04e82e36a4c201b29c98b6bef11e81fe2d Mon Sep 17 00:00:00 2001 From: litetex <40789489+litetex@users.noreply.github.com> Date: Sat, 25 Dec 2021 00:06:06 +0100 Subject: [PATCH] Added fuzzy searching + Some minor code refactoring --- .../newpipe/settings/SettingsActivity.java | 9 ++ .../PreferenceFuzzySearchFunction.java | 121 ++++++++++++++ .../PreferenceSearchConfiguration.java | 11 +- .../PreferenceSearchFragment.java | 2 +- .../similarity/FuzzyScore.java | 148 ++++++++++++++++++ 5 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/similarity/FuzzyScore.java diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java index 88c8fcba5..1f917e771 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java @@ -111,6 +111,15 @@ public class SettingsActivity extends AppCompatActivity return super.onCreateOptionsMenu(menu); } + @Override + public void onBackPressed() { + if (isSearchActive()) { + setSearchActive(false); + return; + } + super.onBackPressed(); + } + @Override public boolean onOptionsItemSelected(final MenuItem item) { final int id = item.getItemId(); diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java new file mode 100644 index 000000000..48f507a41 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java @@ -0,0 +1,121 @@ +package org.schabi.newpipe.settings.preferencesearch; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.settings.preferencesearch.similarity.FuzzyScore; + +import java.util.Comparator; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; + +public class PreferenceFuzzySearchFunction + implements PreferenceSearchConfiguration.PreferenceSearchFunction { + + private static final FuzzyScore FUZZY_SCORE = new FuzzyScore(Locale.ROOT); + + @Override + public Stream search( + final Stream allAvailable, + final String keyword + ) { + final float maxScore = (keyword.length() + 1) * 3 - 2; // First can't get +2 bonus score + + return allAvailable + // General search + // Check all fields if anyone contains something that kind of matches the keyword + .map(item -> new FuzzySearchGeneralDTO(item, keyword)) + .filter(dto -> dto.getScore() / maxScore >= 0.3f) + .map(FuzzySearchGeneralDTO::getItem) + // Specific search - Used for determining order of search results + // Calculate a score based on specific search fields + .map(item -> new FuzzySearchSpecificDTO(item, keyword)) + .sorted(Comparator.comparing(FuzzySearchSpecificDTO::getScore).reversed()) + .map(FuzzySearchSpecificDTO::getItem) + // Limit the amount of search results + .limit(20); + } + + private float computeFuzzyScore( + @NonNull final PreferenceSearchItem item, + @NonNull final Function resolver, + @NonNull final String keyword + ) { + return FUZZY_SCORE.fuzzyScore(resolver.apply(item), keyword); + } + + static class FuzzySearchGeneralDTO { + private final PreferenceSearchItem item; + private final float score; + + FuzzySearchGeneralDTO( + final PreferenceSearchItem item, + final String keyword) { + this.item = item; + this.score = FUZZY_SCORE.fuzzyScore( + TextUtils.join(";", item.getAllRelevantSearchFields()), + keyword); + } + + public PreferenceSearchItem getItem() { + return item; + } + + public float getScore() { + return score; + } + } + + static class FuzzySearchSpecificDTO { + private static final Map, Float> WEIGHT_MAP = Map.of( + // The user will most likely look for the title -> prioritize it + PreferenceSearchItem::getTitle, 1.5f, + // The summary is also important as it usually contains a larger desc + // Example: Searching for '4k' → 'show higher resolution' is shown + PreferenceSearchItem::getSummary, 1f, + // Entries are also important as they provide all known/possible values + // Example: Searching where the resolution can be changed to 720p + PreferenceSearchItem::getEntries, 1f + ); + + private final PreferenceSearchItem item; + private final float score; + + FuzzySearchSpecificDTO( + final PreferenceSearchItem item, + final String keyword) { + this.item = item; + + float attributeScoreSum = 0; + int countOfAttributesWithScore = 0; + for (final Map.Entry, Float> we + : WEIGHT_MAP.entrySet()) { + final String valueToProcess = we.getKey().apply(item); + if (valueToProcess.isEmpty()) { + continue; + } + + attributeScoreSum += + FUZZY_SCORE.fuzzyScore(valueToProcess, keyword) * we.getValue(); + countOfAttributesWithScore++; + } + + if (countOfAttributesWithScore != 0) { + this.score = attributeScoreSum / countOfAttributesWithScore; + } else { + this.score = 0; + } + } + + public PreferenceSearchItem getItem() { + return item; + } + + public float getScore() { + return score; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java index b4d1c8985..37b98035a 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java @@ -21,16 +21,7 @@ public class PreferenceSearchConfiguration { private BinaryOperator breadcrumbConcat = (s1, s2) -> TextUtils.isEmpty(s1) ? s2 : (s1 + " > " + s2); - private PreferenceSearchFunction searcher = - (itemStream, keyword) -> - itemStream - // Filter the items by the keyword - .filter(item -> item.getAllRelevantSearchFields().stream() - .filter(str -> !TextUtils.isEmpty(str)) - .anyMatch(str -> - str.toLowerCase().contains(keyword.toLowerCase()))) - // Limit the search results - .limit(100); + private PreferenceSearchFunction searcher = new PreferenceFuzzySearchFunction(); private final List parserIgnoreElements = Arrays.asList( PreferenceCategory.class.getSimpleName()); diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java index a90d1084e..d9a17da16 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java @@ -81,7 +81,7 @@ public class PreferenceSearchFragment extends Fragment { adapter.setContent(new ArrayList<>(results)); - setEmptyViewShown(!TextUtils.isEmpty(keyword) && results.isEmpty()); + setEmptyViewShown(results.isEmpty()); } private void setEmptyViewShown(final boolean shown) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/similarity/FuzzyScore.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/similarity/FuzzyScore.java new file mode 100644 index 000000000..4ab6bf60a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/similarity/FuzzyScore.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.schabi.newpipe.settings.preferencesearch.similarity; + +import java.util.Locale; + +/** + * A matching algorithm that is similar to the searching algorithms implemented in editors such + * as Sublime Text, TextMate, Atom and others. + * + *

+ * One point is given for every matched character. Subsequent matches yield two bonus points. + * A higher score indicates a higher similarity. + *

+ * + *

+ * This code has been adapted from Apache Commons Lang 3.3. + *

+ * + * @since 1.0 + * + * Note: This class was forked from + * + * apache/commons-text (8cfdafc) FuzzyScore.java + * + */ +public class FuzzyScore { + + /** + * Locale used to change the case of text. + */ + private final Locale locale; + + + /** + * This returns a {@link Locale}-specific {@link FuzzyScore}. + * + * @param locale The string matching logic is case insensitive. + A {@link Locale} is necessary to normalize both Strings to lower case. + * @throws IllegalArgumentException + * This is thrown if the {@link Locale} parameter is {@code null}. + */ + public FuzzyScore(final Locale locale) { + if (locale == null) { + throw new IllegalArgumentException("Locale must not be null"); + } + this.locale = locale; + } + + /** + * Find the Fuzzy Score which indicates the similarity score between two + * Strings. + * + *
+     * score.fuzzyScore(null, null)                          = IllegalArgumentException
+     * score.fuzzyScore("not null", null)                    = IllegalArgumentException
+     * score.fuzzyScore(null, "not null")                    = IllegalArgumentException
+     * score.fuzzyScore("", "")                              = 0
+     * score.fuzzyScore("Workshop", "b")                     = 0
+     * score.fuzzyScore("Room", "o")                         = 1
+     * score.fuzzyScore("Workshop", "w")                     = 1
+     * score.fuzzyScore("Workshop", "ws")                    = 2
+     * score.fuzzyScore("Workshop", "wo")                    = 4
+     * score.fuzzyScore("Apache Software Foundation", "asf") = 3
+     * 
+ * + * @param term a full term that should be matched against, must not be null + * @param query the query that will be matched against a term, must not be + * null + * @return result score + * @throws IllegalArgumentException if the term or query is {@code null} + */ + public Integer fuzzyScore(final CharSequence term, final CharSequence query) { + if (term == null || query == null) { + throw new IllegalArgumentException("CharSequences must not be null"); + } + + // fuzzy logic is case insensitive. We normalize the Strings to lower + // case right from the start. Turning characters to lower case + // via Character.toLowerCase(char) is unfortunately insufficient + // as it does not accept a locale. + final String termLowerCase = term.toString().toLowerCase(locale); + final String queryLowerCase = query.toString().toLowerCase(locale); + + // the resulting score + int score = 0; + + // the position in the term which will be scanned next for potential + // query character matches + int termIndex = 0; + + // index of the previously matched character in the term + int previousMatchingCharacterIndex = Integer.MIN_VALUE; + + for (int queryIndex = 0; queryIndex < queryLowerCase.length(); queryIndex++) { + final char queryChar = queryLowerCase.charAt(queryIndex); + + boolean termCharacterMatchFound = false; + for (; termIndex < termLowerCase.length() + && !termCharacterMatchFound; termIndex++) { + final char termChar = termLowerCase.charAt(termIndex); + + if (queryChar == termChar) { + // simple character matches result in one point + score++; + + // subsequent character matches further improve + // the score. + if (previousMatchingCharacterIndex + 1 == termIndex) { + score += 2; + } + + previousMatchingCharacterIndex = termIndex; + + // we can leave the nested loop. Every character in the + // query can match at most one character in the term. + termCharacterMatchFound = true; + } + } + } + + return score; + } + + /** + * Gets the locale. + * + * @return The locale + */ + public Locale getLocale() { + return locale; + } + +}