Added fuzzy searching + Some minor code refactoring

This commit is contained in:
litetex 2021-12-25 00:06:06 +01:00
parent 7fc0a3841a
commit 52542e04e8
5 changed files with 280 additions and 11 deletions

View File

@ -111,6 +111,15 @@ public class SettingsActivity extends AppCompatActivity
return super.onCreateOptionsMenu(menu); return super.onCreateOptionsMenu(menu);
} }
@Override
public void onBackPressed() {
if (isSearchActive()) {
setSearchActive(false);
return;
}
super.onBackPressed();
}
@Override @Override
public boolean onOptionsItemSelected(final MenuItem item) { public boolean onOptionsItemSelected(final MenuItem item) {
final int id = item.getItemId(); final int id = item.getItemId();

View File

@ -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<PreferenceSearchItem> search(
final Stream<PreferenceSearchItem> 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<PreferenceSearchItem, String> 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<Function<PreferenceSearchItem, String>, 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<Function<PreferenceSearchItem, String>, 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;
}
}
}

View File

@ -21,16 +21,7 @@ public class PreferenceSearchConfiguration {
private BinaryOperator<String> breadcrumbConcat = private BinaryOperator<String> breadcrumbConcat =
(s1, s2) -> TextUtils.isEmpty(s1) ? s2 : (s1 + " > " + s2); (s1, s2) -> TextUtils.isEmpty(s1) ? s2 : (s1 + " > " + s2);
private PreferenceSearchFunction searcher = private PreferenceSearchFunction searcher = new PreferenceFuzzySearchFunction();
(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 final List<String> parserIgnoreElements = Arrays.asList( private final List<String> parserIgnoreElements = Arrays.asList(
PreferenceCategory.class.getSimpleName()); PreferenceCategory.class.getSimpleName());

View File

@ -81,7 +81,7 @@ public class PreferenceSearchFragment extends Fragment {
adapter.setContent(new ArrayList<>(results)); adapter.setContent(new ArrayList<>(results));
setEmptyViewShown(!TextUtils.isEmpty(keyword) && results.isEmpty()); setEmptyViewShown(results.isEmpty());
} }
private void setEmptyViewShown(final boolean shown) { private void setEmptyViewShown(final boolean shown) {

View File

@ -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.
*
* <p>
* One point is given for every matched character. Subsequent matches yield two bonus points.
* A higher score indicates a higher similarity.
* </p>
*
* <p>
* This code has been adapted from Apache Commons Lang 3.3.
* </p>
*
* @since 1.0
*
* Note: This class was forked from
* <a href="https://git.io/JyYJg">
* apache/commons-text (8cfdafc) FuzzyScore.java
* </a>
*/
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.
*
* <pre>
* 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
* </pre>
*
* @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;
}
}