From 790674f53101bc629f4cc20201ba443378692993 Mon Sep 17 00:00:00 2001 From: marunjar Date: Sun, 15 Dec 2024 10:07:27 +0100 Subject: [PATCH 1/4] extract interface for FuzzyScore - extract interface for FuzzyScore to have possibility of adding new implementation --- .../java/fr/neamar/kiss/CustomIconDialog.java | 5 +- .../fr/neamar/kiss/adapter/RecordAdapter.java | 7 +- .../neamar/kiss/dataprovider/AppProvider.java | 8 +- .../kiss/dataprovider/ContactsProvider.java | 9 +- .../kiss/dataprovider/ShortcutsProvider.java | 8 +- .../simpleprovider/SettingsProvider.java | 21 +- .../main/java/fr/neamar/kiss/pojo/Pojo.java | 8 +- .../java/fr/neamar/kiss/result/AppResult.java | 2 +- .../fr/neamar/kiss/result/ContactsResult.java | 2 +- .../fr/neamar/kiss/result/PhoneResult.java | 2 +- .../java/fr/neamar/kiss/result/Result.java | 5 +- .../fr/neamar/kiss/result/SearchResult.java | 2 +- .../fr/neamar/kiss/result/SettingsResult.java | 2 +- .../neamar/kiss/result/ShortcutsResult.java | 2 +- .../fr/neamar/kiss/result/TagDummyResult.java | 2 +- .../fr/neamar/kiss/searcher/Searcher.java | 2 +- .../neamar/kiss/utils/fuzzy/FuzzyFactory.java | 17 + .../neamar/kiss/utils/fuzzy/FuzzyScore.java | 22 + .../FuzzyScoreV1.java} | 584 +++++++++--------- .../fr/neamar/kiss/utils/fuzzy/MatchInfo.java | 43 ++ .../FuzzyScoreV1Test.java} | 10 +- 21 files changed, 414 insertions(+), 349 deletions(-) create mode 100644 app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyFactory.java create mode 100644 app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScore.java rename app/src/main/java/fr/neamar/kiss/utils/{FuzzyScore.java => fuzzy/FuzzyScoreV1.java} (82%) create mode 100644 app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java rename app/src/test/java/fr/neamar/kiss/utils/{FuzzyScoreTest.java => fuzzy/FuzzyScoreV1Test.java} (93%) diff --git a/app/src/main/java/fr/neamar/kiss/CustomIconDialog.java b/app/src/main/java/fr/neamar/kiss/CustomIconDialog.java index 520a632f39..b7a3995fe3 100644 --- a/app/src/main/java/fr/neamar/kiss/CustomIconDialog.java +++ b/app/src/main/java/fr/neamar/kiss/CustomIconDialog.java @@ -46,7 +46,8 @@ import fr.neamar.kiss.icons.SystemIconPack; import fr.neamar.kiss.normalizer.StringNormalizer; import fr.neamar.kiss.utils.DrawableUtils; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyFactory; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; import fr.neamar.kiss.utils.UserHandle; import fr.neamar.kiss.utils.Utilities; @@ -311,7 +312,7 @@ private void refreshList() { Collection drawables = ((IconPackXML) iconPack).getDrawableList(); if (drawables != null) { StringNormalizer.Result normalized = StringNormalizer.normalizeWithResult(mSearch.getText(), true); - FuzzyScore fuzzyScore = new FuzzyScore(normalized.codePoints); + FuzzyScore fuzzyScore = FuzzyFactory.createFuzzyScore(getActivity(), normalized.codePoints); for (IconPackXML.DrawableInfo info : drawables) { if (fuzzyScore.match(info.getDrawableName()).match) mIconData.add(new IconData((IconPackXML) iconPack, info)); diff --git a/app/src/main/java/fr/neamar/kiss/adapter/RecordAdapter.java b/app/src/main/java/fr/neamar/kiss/adapter/RecordAdapter.java index a6a03ecf0a..5b9446582d 100644 --- a/app/src/main/java/fr/neamar/kiss/adapter/RecordAdapter.java +++ b/app/src/main/java/fr/neamar/kiss/adapter/RecordAdapter.java @@ -26,7 +26,8 @@ import fr.neamar.kiss.result.ShortcutsResult; import fr.neamar.kiss.searcher.QueryInterface; import fr.neamar.kiss.ui.ListPopup; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyFactory; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; public class RecordAdapter extends BaseAdapter implements SectionIndexer { private final QueryInterface parent; @@ -128,12 +129,12 @@ public void removeResult(Context context, Result result) { parent.temporarilyDisableTranscriptMode(); } - public void updateResults(List> results, boolean isRefresh, String query) { + public void updateResults(@NonNull Context context, List> results, boolean isRefresh, String query) { this.results.clear(); this.results.addAll(results); StringNormalizer.Result queryNormalized = StringNormalizer.normalizeWithResult(query, false); - fuzzyScore = new FuzzyScore(queryNormalized.codePoints, true); + fuzzyScore = FuzzyFactory.createFuzzyScore(context, queryNormalized.codePoints, true); notifyDataSetChanged(); if (isRefresh) { diff --git a/app/src/main/java/fr/neamar/kiss/dataprovider/AppProvider.java b/app/src/main/java/fr/neamar/kiss/dataprovider/AppProvider.java index 366f543faf..f6ca574f8b 100644 --- a/app/src/main/java/fr/neamar/kiss/dataprovider/AppProvider.java +++ b/app/src/main/java/fr/neamar/kiss/dataprovider/AppProvider.java @@ -19,8 +19,10 @@ import fr.neamar.kiss.normalizer.StringNormalizer; import fr.neamar.kiss.pojo.AppPojo; import fr.neamar.kiss.searcher.Searcher; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyFactory; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; import fr.neamar.kiss.utils.UserHandle; +import fr.neamar.kiss.utils.fuzzy.MatchInfo; public class AppProvider extends Provider { @@ -127,7 +129,7 @@ public void requestResults(String query, Searcher searcher) { return; } - FuzzyScore fuzzyScore = new FuzzyScore(queryNormalized.codePoints); + FuzzyScore fuzzyScore = FuzzyFactory.createFuzzyScore(this, queryNormalized.codePoints); for (AppPojo pojo : getPojos()) { // exclude apps from results @@ -139,7 +141,7 @@ public void requestResults(String query, Searcher searcher) { continue; } - FuzzyScore.MatchInfo matchInfo = fuzzyScore.match(pojo.normalizedName.codePoints); + MatchInfo matchInfo = fuzzyScore.match(pojo.normalizedName.codePoints); boolean match = pojo.updateMatchingRelevance(matchInfo, false); // check relevance for tags diff --git a/app/src/main/java/fr/neamar/kiss/dataprovider/ContactsProvider.java b/app/src/main/java/fr/neamar/kiss/dataprovider/ContactsProvider.java index 3804fe0128..8b521bc2ba 100644 --- a/app/src/main/java/fr/neamar/kiss/dataprovider/ContactsProvider.java +++ b/app/src/main/java/fr/neamar/kiss/dataprovider/ContactsProvider.java @@ -8,7 +8,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -17,8 +16,10 @@ import fr.neamar.kiss.normalizer.StringNormalizer; import fr.neamar.kiss.pojo.ContactsPojo; import fr.neamar.kiss.searcher.Searcher; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyFactory; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; import fr.neamar.kiss.utils.Permission; +import fr.neamar.kiss.utils.fuzzy.MatchInfo; public class ContactsProvider extends Provider { private final static String TAG = ContactsProvider.class.getSimpleName(); @@ -88,10 +89,10 @@ public void requestResults(String query, Searcher searcher) { return; } - FuzzyScore fuzzyScore = new FuzzyScore(queryNormalized.codePoints); + FuzzyScore fuzzyScore = FuzzyFactory.createFuzzyScore(this, queryNormalized.codePoints); for (ContactsPojo pojo : getPojos()) { - FuzzyScore.MatchInfo matchInfo; + MatchInfo matchInfo; boolean match = false; if (pojo.normalizedName != null) { diff --git a/app/src/main/java/fr/neamar/kiss/dataprovider/ShortcutsProvider.java b/app/src/main/java/fr/neamar/kiss/dataprovider/ShortcutsProvider.java index 252e2424e7..79208c7bfd 100644 --- a/app/src/main/java/fr/neamar/kiss/dataprovider/ShortcutsProvider.java +++ b/app/src/main/java/fr/neamar/kiss/dataprovider/ShortcutsProvider.java @@ -18,8 +18,10 @@ import fr.neamar.kiss.normalizer.StringNormalizer; import fr.neamar.kiss.pojo.ShortcutPojo; import fr.neamar.kiss.searcher.Searcher; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyFactory; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; import fr.neamar.kiss.utils.ShortcutUtil; +import fr.neamar.kiss.utils.fuzzy.MatchInfo; public class ShortcutsProvider extends Provider { private static boolean notifiedKissNotDefaultLauncher = false; @@ -87,7 +89,7 @@ public void requestResults(String query, Searcher searcher) { return; } - FuzzyScore fuzzyScore = new FuzzyScore(queryNormalized.codePoints); + FuzzyScore fuzzyScore = FuzzyFactory.createFuzzyScore(this, queryNormalized.codePoints); for (ShortcutPojo pojo : getPojos()) { // exclude favorites from results @@ -95,7 +97,7 @@ public void requestResults(String query, Searcher searcher) { continue; } - FuzzyScore.MatchInfo matchInfo = fuzzyScore.match(pojo.normalizedName.codePoints); + MatchInfo matchInfo = fuzzyScore.match(pojo.normalizedName.codePoints); boolean match = pojo.updateMatchingRelevance(matchInfo, false); // check relevance for tags diff --git a/app/src/main/java/fr/neamar/kiss/dataprovider/simpleprovider/SettingsProvider.java b/app/src/main/java/fr/neamar/kiss/dataprovider/simpleprovider/SettingsProvider.java index d9b2efbfbc..f68aa5f62f 100644 --- a/app/src/main/java/fr/neamar/kiss/dataprovider/simpleprovider/SettingsProvider.java +++ b/app/src/main/java/fr/neamar/kiss/dataprovider/simpleprovider/SettingsProvider.java @@ -10,6 +10,7 @@ import androidx.annotation.DrawableRes; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -18,13 +19,15 @@ import fr.neamar.kiss.normalizer.StringNormalizer; import fr.neamar.kiss.pojo.SettingPojo; import fr.neamar.kiss.searcher.Searcher; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyFactory; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.MatchInfo; public class SettingsProvider extends SimpleProvider { private final static String SCHEME = "setting://"; private final String settingName; private final List pojos; - private final SharedPreferences prefs; + private final WeakReference contextReference; public SettingsProvider(Context context) { pojos = new ArrayList<>(); @@ -61,8 +64,7 @@ public SettingsProvider(Context context) { settingName = context.getString(R.string.settings_prefix).toLowerCase(Locale.ROOT); - this.prefs = PreferenceManager.getDefaultSharedPreferences(context); - + this.contextReference = new WeakReference<>(context); } private void assignName(SettingPojo pojo, String name) { @@ -88,20 +90,25 @@ private SettingPojo createPojo(String name, String settingName, @DrawableRes int @Override public void requestResults(String query, Searcher searcher) { + Context context = contextReference.get(); + if (context == null) { + return; + } + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); if (!prefs.getBoolean("enable-settings", true)) { return; } StringNormalizer.Result queryNormalized = StringNormalizer.normalizeWithResult(query, false); - if (queryNormalized.codePoints.length == 0) { return; } - FuzzyScore fuzzyScore = new FuzzyScore(queryNormalized.codePoints); + FuzzyScore fuzzyScore = FuzzyFactory.createFuzzyScore(context, queryNormalized.codePoints); for (SettingPojo pojo : pojos) { - FuzzyScore.MatchInfo matchInfo = fuzzyScore.match(pojo.normalizedName.codePoints); + MatchInfo matchInfo = fuzzyScore.match(pojo.normalizedName.codePoints); boolean match = pojo.updateMatchingRelevance(matchInfo, false); if (!match) { diff --git a/app/src/main/java/fr/neamar/kiss/pojo/Pojo.java b/app/src/main/java/fr/neamar/kiss/pojo/Pojo.java index b6f12e249b..950f913942 100644 --- a/app/src/main/java/fr/neamar/kiss/pojo/Pojo.java +++ b/app/src/main/java/fr/neamar/kiss/pojo/Pojo.java @@ -1,7 +1,7 @@ package fr.neamar.kiss.pojo; import fr.neamar.kiss.normalizer.StringNormalizer; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.MatchInfo; public abstract class Pojo { public static final String DEFAULT_ID = "(none)"; @@ -77,10 +77,10 @@ public String getFavoriteId() { * Updates relevance of this pojo with score of given {@code matchInfo} if there is a match. * * @param matchInfo used for update - * @param matched flag to indicate if there was already a match before. If {@code matched} is false relevance is always set to {@link fr.neamar.kiss.utils.FuzzyScore.MatchInfo#score}, else it will be only set if {@link fr.neamar.kiss.utils.FuzzyScore.MatchInfo#score} is also higher than current value of relevance. - * @return true, if {@link fr.neamar.kiss.utils.FuzzyScore.MatchInfo#match} is true and relevance was updated, else value of {@code matched} is returned as is + * @param matched flag to indicate if there was already a match before. If {@code matched} is false relevance is always set to {@link MatchInfo#score}, else it will be only set if {@link MatchInfo#score} is also higher than current value of relevance. + * @return true, if {@link MatchInfo#match} is true and relevance was updated, else value of {@code matched} is returned as is */ - public boolean updateMatchingRelevance(FuzzyScore.MatchInfo matchInfo, boolean matched) { + public boolean updateMatchingRelevance(MatchInfo matchInfo, boolean matched) { if (matchInfo.match && (!matched || matchInfo.score > relevance)) { this.relevance = matchInfo.score; return true; diff --git a/app/src/main/java/fr/neamar/kiss/result/AppResult.java b/app/src/main/java/fr/neamar/kiss/result/AppResult.java index 8e70efed91..bde6824a34 100644 --- a/app/src/main/java/fr/neamar/kiss/result/AppResult.java +++ b/app/src/main/java/fr/neamar/kiss/result/AppResult.java @@ -43,7 +43,7 @@ import fr.neamar.kiss.ui.GoogleCalendarIcon; import fr.neamar.kiss.ui.ListPopup; import fr.neamar.kiss.utils.DrawableUtils; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; import fr.neamar.kiss.utils.PackageManagerUtils; import fr.neamar.kiss.utils.SpaceTokenizer; diff --git a/app/src/main/java/fr/neamar/kiss/result/ContactsResult.java b/app/src/main/java/fr/neamar/kiss/result/ContactsResult.java index e524f07ea6..42fa2c7ca1 100644 --- a/app/src/main/java/fr/neamar/kiss/result/ContactsResult.java +++ b/app/src/main/java/fr/neamar/kiss/result/ContactsResult.java @@ -34,7 +34,7 @@ import fr.neamar.kiss.ui.ImprovedQuickContactBadge; import fr.neamar.kiss.ui.ListPopup; import fr.neamar.kiss.ui.ShapedContactBadge; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; import fr.neamar.kiss.utils.MimeTypeUtils; import fr.neamar.kiss.utils.PackageManagerUtils; import fr.neamar.kiss.utils.UserHandle; diff --git a/app/src/main/java/fr/neamar/kiss/result/PhoneResult.java b/app/src/main/java/fr/neamar/kiss/result/PhoneResult.java index cf622e86cc..edeb1f39ab 100644 --- a/app/src/main/java/fr/neamar/kiss/result/PhoneResult.java +++ b/app/src/main/java/fr/neamar/kiss/result/PhoneResult.java @@ -21,7 +21,7 @@ import fr.neamar.kiss.adapter.RecordAdapter; import fr.neamar.kiss.pojo.PhonePojo; import fr.neamar.kiss.ui.ListPopup; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; public class PhoneResult extends CallResult { diff --git a/app/src/main/java/fr/neamar/kiss/result/Result.java b/app/src/main/java/fr/neamar/kiss/result/Result.java index a7fc4bedaa..4a89a19106 100644 --- a/app/src/main/java/fr/neamar/kiss/result/Result.java +++ b/app/src/main/java/fr/neamar/kiss/result/Result.java @@ -55,7 +55,8 @@ import fr.neamar.kiss.pojo.TagDummyPojo; import fr.neamar.kiss.searcher.QueryInterface; import fr.neamar.kiss.ui.ListPopup; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.MatchInfo; public abstract class Result { @@ -135,7 +136,7 @@ void displayHighlighted(String text, List> positions, Tex boolean displayHighlighted(StringNormalizer.Result normalized, String text, FuzzyScore fuzzyScore, TextView view, Context context) { - FuzzyScore.MatchInfo matchInfo = fuzzyScore.match(normalized.codePoints); + MatchInfo matchInfo = fuzzyScore.match(normalized.codePoints); if (!matchInfo.match) { view.setText(text); diff --git a/app/src/main/java/fr/neamar/kiss/result/SearchResult.java b/app/src/main/java/fr/neamar/kiss/result/SearchResult.java index 049250eff9..0fb0b33fe0 100644 --- a/app/src/main/java/fr/neamar/kiss/result/SearchResult.java +++ b/app/src/main/java/fr/neamar/kiss/result/SearchResult.java @@ -30,7 +30,7 @@ import fr.neamar.kiss.pojo.SearchPojo; import fr.neamar.kiss.ui.ListPopup; import fr.neamar.kiss.utils.ClipboardUtils; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; import fr.neamar.kiss.utils.PackageManagerUtils; import fr.neamar.kiss.utils.UserHandle; diff --git a/app/src/main/java/fr/neamar/kiss/result/SettingsResult.java b/app/src/main/java/fr/neamar/kiss/result/SettingsResult.java index ebb54c0be7..9b57f93dd4 100644 --- a/app/src/main/java/fr/neamar/kiss/result/SettingsResult.java +++ b/app/src/main/java/fr/neamar/kiss/result/SettingsResult.java @@ -16,7 +16,7 @@ import fr.neamar.kiss.R; import fr.neamar.kiss.pojo.SettingPojo; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; public class SettingsResult extends Result { private static final String TAG = SettingsResult.class.getSimpleName(); diff --git a/app/src/main/java/fr/neamar/kiss/result/ShortcutsResult.java b/app/src/main/java/fr/neamar/kiss/result/ShortcutsResult.java index f674ccd3ab..19d41acbd3 100644 --- a/app/src/main/java/fr/neamar/kiss/result/ShortcutsResult.java +++ b/app/src/main/java/fr/neamar/kiss/result/ShortcutsResult.java @@ -35,7 +35,7 @@ import fr.neamar.kiss.pojo.ShortcutPojo; import fr.neamar.kiss.ui.ListPopup; import fr.neamar.kiss.utils.DrawableUtils; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; import fr.neamar.kiss.utils.PackageManagerUtils; import fr.neamar.kiss.utils.ShortcutUtil; import fr.neamar.kiss.utils.SpaceTokenizer; diff --git a/app/src/main/java/fr/neamar/kiss/result/TagDummyResult.java b/app/src/main/java/fr/neamar/kiss/result/TagDummyResult.java index 9e8a59c32b..16d246e61f 100644 --- a/app/src/main/java/fr/neamar/kiss/result/TagDummyResult.java +++ b/app/src/main/java/fr/neamar/kiss/result/TagDummyResult.java @@ -25,7 +25,7 @@ import fr.neamar.kiss.UIColors; import fr.neamar.kiss.pojo.TagDummyPojo; import fr.neamar.kiss.utils.DrawableUtils; -import fr.neamar.kiss.utils.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; import fr.neamar.kiss.utils.Utilities; public class TagDummyResult extends Result { diff --git a/app/src/main/java/fr/neamar/kiss/searcher/Searcher.java b/app/src/main/java/fr/neamar/kiss/searcher/Searcher.java index cda32000d7..fc3c6c3e7a 100644 --- a/app/src/main/java/fr/neamar/kiss/searcher/Searcher.java +++ b/app/src/main/java/fr/neamar/kiss/searcher/Searcher.java @@ -121,7 +121,7 @@ protected void onPostExecute(Void param) { activity.beforeListChange(); - activity.adapter.updateResults(results, isRefresh, query); + activity.adapter.updateResults(activity, results, isRefresh, query); activity.afterListChange(); } diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyFactory.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyFactory.java new file mode 100644 index 0000000000..d104915271 --- /dev/null +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyFactory.java @@ -0,0 +1,17 @@ +package fr.neamar.kiss.utils.fuzzy; + +import android.content.Context; + +import androidx.annotation.NonNull; + +public class FuzzyFactory { + + public static FuzzyScore createFuzzyScore(@NonNull Context context, int[] pattern) { + return createFuzzyScore(context, pattern, false); + } + + public static FuzzyScore createFuzzyScore(@NonNull Context context, int[] pattern, boolean detailedMatchIndices) { + return new FuzzyScoreV1(pattern, detailedMatchIndices); + } + +} diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScore.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScore.java new file mode 100644 index 0000000000..5ae61a8f55 --- /dev/null +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScore.java @@ -0,0 +1,22 @@ +package fr.neamar.kiss.utils.fuzzy; + +public interface FuzzyScore { + + FuzzyScore setFullWordBonus(int full_word_bonus); + + FuzzyScore setAdjacencyBonus(int adjacency_bonus); + + FuzzyScore setSeparatorBonus(int separator_bonus); + + FuzzyScore setCamelBonus(int camel_bonus); + + FuzzyScore setLeadingLetterPenalty(int leading_letter_penalty); + + FuzzyScore setMaxLeadingLetterPenalty(int max_leading_letter_penalty); + + FuzzyScore setUnmatchedLetterPenalty(int unmatched_letter_penalty); + + MatchInfo match(CharSequence text); + + MatchInfo match(int[] text); +} diff --git a/app/src/main/java/fr/neamar/kiss/utils/FuzzyScore.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1.java similarity index 82% rename from app/src/main/java/fr/neamar/kiss/utils/FuzzyScore.java rename to app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1.java index 71b759b942..5e0a391bcc 100644 --- a/app/src/main/java/fr/neamar/kiss/utils/FuzzyScore.java +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1.java @@ -1,308 +1,276 @@ -package fr.neamar.kiss.utils; - -import android.util.Pair; - -import java.util.ArrayList; -import java.util.List; - -/** - * A Sublime Text inspired fuzzy match algorithm - * https://github.com/forrestthewoods/lib_fts/blob/master/docs/fuzzy_match.md - *

- * match("otw", "Power of the Wild", info) = true, info.score = 14 - * match("otw", "Druid of the Claw", info) = true, info.score = -3 - * match("otw", "Frostwolf Grunt", info) = true, info.score = -13 - */ -@SuppressWarnings("CanIgnoreReturnValueSuggester") -public class FuzzyScore { - private final int patternLength; - private final int[] patternChar; - private final int[] patternLower; - /** - * bonus if all characters match (useful for short queries) - * E.g. "js" should match "js" with a higher score than "John Smith" - */ - private int full_word_bonus; - /** - * bonus for adjacent matches - */ - private int adjacency_bonus; - /** - * bonus if match occurs after a separator - */ - private int separator_bonus; - /** - * bonus if match is uppercase and prev is lower - */ - private int camel_bonus; - /** - * penalty applied for every letter in str before the first match - */ - private int leading_letter_penalty; - /** - * maximum penalty for leading letters - */ - private int max_leading_letter_penalty; - /** - * penalty for every letter that doesn't matter - */ - private int unmatched_letter_penalty; - - private final MatchInfo matchInfo; - - public FuzzyScore(int[] pattern, boolean detailedMatchIndices) { - super(); - patternLength = pattern.length; - patternChar = new int[patternLength]; - patternLower = new int[patternLength]; - for (int i = 0; i < patternLower.length; i += 1) { - patternChar[i] = pattern[i]; - patternLower[i] = Character.toLowerCase(pattern[i]); - } - full_word_bonus = 100; - adjacency_bonus = 10; - separator_bonus = 5; - camel_bonus = 10; - leading_letter_penalty = -3; - max_leading_letter_penalty = -9; - unmatched_letter_penalty = -1; - if (detailedMatchIndices) { - matchInfo = new MatchInfo(patternLength); - } else { - matchInfo = new MatchInfo(); - } - } - - public FuzzyScore(int[] pattern) { - this(pattern, false); - } - - public FuzzyScore setFullWordBonus(int full_word_bonus) { - this.full_word_bonus = full_word_bonus; - return this; - } - - public FuzzyScore setAdjacencyBonus(int adjacency_bonus) { - this.adjacency_bonus = adjacency_bonus; - return this; - } - - public FuzzyScore setSeparatorBonus(int separator_bonus) { - this.separator_bonus = separator_bonus; - return this; - } - - public FuzzyScore setCamelBonus(int camel_bonus) { - this.camel_bonus = camel_bonus; - return this; - } - - public FuzzyScore setLeadingLetterPenalty(int leading_letter_penalty) { - this.leading_letter_penalty = leading_letter_penalty; - return this; - } - - public FuzzyScore setMaxLeadingLetterPenalty(int max_leading_letter_penalty) { - this.max_leading_letter_penalty = max_leading_letter_penalty; - return this; - } - - public FuzzyScore setUnmatchedLetterPenalty(int unmatched_letter_penalty) { - this.unmatched_letter_penalty = unmatched_letter_penalty; - return this; - } - - /** - * @param text string where to search - * @return true if each character in pattern is found sequentially within text - */ - public MatchInfo match(CharSequence text) { - int idx = 0; - int idxCodepoint = 0; - int textLength = text.length(); - int[] codepoints = new int[Character.codePointCount(text, 0, textLength)]; - while (idx < textLength) { - int codepoint = Character.codePointAt(text, idx); - codepoints[idxCodepoint] = codepoint; - idx += Character.charCount(codepoint); - idxCodepoint += 1; - } - return match(codepoints); - } - - /** - * @param text string converted to codepoints - * @return true if each character in pattern is found sequentially within text - */ - public MatchInfo match(int[] text) { - // Loop variables - int score = 0; - int patternIdx = 0; - int strIdx = 0; - int strLength = text.length; - boolean fullWord = false; - boolean prevMatched = false; - boolean prevLower = false; - boolean prevSeparator = true; // true so if first letter match gets separator bonus - - // Use "best" matched letter if multiple string letters match the pattern - Integer bestLetter = null; - Integer bestLower = null; - Integer bestLetterIdx = null; - int bestLetterScore = 0; - - if (matchInfo.matchedIndices != null) { - matchInfo.matchedIndices.clear(); - } - - // Loop over strings - while (strIdx != strLength) { - Integer patternChar = null; - Integer patternLower = null; - if (patternIdx != patternLength) { - patternChar = this.patternChar[patternIdx]; - patternLower = this.patternLower[patternIdx]; - } - int strChar = text[strIdx]; - int strLower = Character.toLowerCase(strChar); - int strUpper = Character.toUpperCase(strChar); - boolean isWhitespace = Character.isWhitespace(strChar); - - boolean nextMatch = patternChar != null && patternLower == strLower; - boolean rematch = bestLetter != null && bestLower == strLower; - - boolean advanced = nextMatch && bestLetter != null; - boolean patternRepeat = bestLetter != null && patternChar != null && patternLower.equals(bestLower); - if (advanced || patternRepeat) { - score += bestLetterScore; - if (matchInfo.matchedIndices != null) { - matchInfo.matchedIndices.add(bestLetterIdx); - } - bestLetter = null; - bestLower = null; - bestLetterIdx = null; - bestLetterScore = 0; - } - - // Current char is a separator and we have matched all the previous characters, apply - // the full match bonus - if (isWhitespace && fullWord) { - score += full_word_bonus; - } - - if (nextMatch || rematch) { - int newScore = 0; - - // Apply penalty for each letter before the first pattern match - // Note: std::max because penalties are negative values. So max is smallest penalty. - if (patternIdx == 0) { - int penalty = Math.max(strIdx * leading_letter_penalty, max_leading_letter_penalty); - score += penalty; - } - - // Apply bonus for consecutive bonuses - if (prevMatched && !rematch) { - newScore += adjacency_bonus; - } - - // Apply bonus for matches after a separator - if (prevSeparator) { - newScore += separator_bonus; - } - - // Apply bonus across camel case boundaries. Includes "clever" isLetter check. - if (prevLower && strChar == strUpper && strLower != strUpper) { - newScore += camel_bonus; - } - - // Update pattern index IF the next pattern letter was matched - if (nextMatch) { - ++patternIdx; - } - - // Update best letter in text which may be for a "next" letter or a "rematch" - if (newScore >= bestLetterScore) { - - // Apply penalty for now skipped letter - if (bestLetter != null) { - score += unmatched_letter_penalty; - } - - bestLetter = strChar; - bestLower = strLower; - bestLetterIdx = strIdx; - bestLetterScore = newScore; - - if (prevSeparator) { - fullWord = true; - } - } - - prevMatched = true; - } else { - score += unmatched_letter_penalty; - prevMatched = false; - fullWord = false; - } - - // Includes "clever" isLetter check. - prevLower = strChar == strLower && strLower != strUpper; - prevSeparator = isWhitespace; - - ++strIdx; - } - - // Apply score for last match - if (bestLetter != null) { - score += bestLetterScore; - if (matchInfo.matchedIndices != null) { - matchInfo.matchedIndices.add(bestLetterIdx); - } - } - // Last word full match bonus - if (fullWord) { - score += full_word_bonus; - } - - matchInfo.match = patternIdx == patternLength; - matchInfo.score = score; - return matchInfo; - } - - public static class MatchInfo { - /** - * higher is better match. Value has no intrinsic meaning. Range varies with pattern. - * Can only compare scores with same search pattern. - */ - public int score; - public boolean match; - final ArrayList matchedIndices; - - MatchInfo() { - matchedIndices = null; - } - - MatchInfo(int patternLength) { - matchedIndices = new ArrayList<>(patternLength); - } - - public List> getMatchedSequences() { - assert this.matchedIndices != null; - // compute pair match indices - List> positions = new ArrayList<>(this.matchedIndices.size()); - int start = this.matchedIndices.get(0); - int end = start + 1; - for (int i = 1; i < this.matchedIndices.size(); i += 1) { - if (end == this.matchedIndices.get(i)) { - end += 1; - } else { - positions.add(new Pair<>(start, end)); - start = this.matchedIndices.get(i); - end = start + 1; - } - } - positions.add(new Pair<>(start, end)); - return positions; - } - } -} +package fr.neamar.kiss.utils.fuzzy; + +/** + * A Sublime Text inspired fuzzy match algorithm + * https://github.com/forrestthewoods/lib_fts/blob/master/docs/fuzzy_match.md + *

+ * match("otw", "Power of the Wild", info) = true, info.score = 14 + * match("otw", "Druid of the Claw", info) = true, info.score = -3 + * match("otw", "Frostwolf Grunt", info) = true, info.score = -13 + */ +@SuppressWarnings("CanIgnoreReturnValueSuggester") +public class FuzzyScoreV1 implements FuzzyScore { + private final int patternLength; + private final int[] patternChar; + private final int[] patternLower; + /** + * bonus if all characters match (useful for short queries) + * E.g. "js" should match "js" with a higher score than "John Smith" + */ + private int full_word_bonus; + /** + * bonus for adjacent matches + */ + private int adjacency_bonus; + /** + * bonus if match occurs after a separator + */ + private int separator_bonus; + /** + * bonus if match is uppercase and prev is lower + */ + private int camel_bonus; + /** + * penalty applied for every letter in str before the first match + */ + private int leading_letter_penalty; + /** + * maximum penalty for leading letters + */ + private int max_leading_letter_penalty; + /** + * penalty for every letter that doesn't matter + */ + private int unmatched_letter_penalty; + + private final MatchInfo matchInfo; + + public FuzzyScoreV1(int[] pattern, boolean detailedMatchIndices) { + super(); + patternLength = pattern.length; + patternChar = new int[patternLength]; + patternLower = new int[patternLength]; + for (int i = 0; i < patternLower.length; i += 1) { + patternChar[i] = pattern[i]; + patternLower[i] = Character.toLowerCase(pattern[i]); + } + full_word_bonus = 100; + adjacency_bonus = 10; + separator_bonus = 5; + camel_bonus = 10; + leading_letter_penalty = -3; + max_leading_letter_penalty = -9; + unmatched_letter_penalty = -1; + if (detailedMatchIndices) { + matchInfo = new MatchInfo(patternLength); + } else { + matchInfo = new MatchInfo(); + } + } + + public FuzzyScoreV1(int[] pattern) { + this(pattern, false); + } + + @Override + public FuzzyScore setFullWordBonus(int full_word_bonus) { + this.full_word_bonus = full_word_bonus; + return this; + } + + @Override + public FuzzyScore setAdjacencyBonus(int adjacency_bonus) { + this.adjacency_bonus = adjacency_bonus; + return this; + } + + @Override + public FuzzyScore setSeparatorBonus(int separator_bonus) { + this.separator_bonus = separator_bonus; + return this; + } + + @Override + public FuzzyScore setCamelBonus(int camel_bonus) { + this.camel_bonus = camel_bonus; + return this; + } + + @Override + public FuzzyScore setLeadingLetterPenalty(int leading_letter_penalty) { + this.leading_letter_penalty = leading_letter_penalty; + return this; + } + + @Override + public FuzzyScore setMaxLeadingLetterPenalty(int max_leading_letter_penalty) { + this.max_leading_letter_penalty = max_leading_letter_penalty; + return this; + } + + @Override + public FuzzyScore setUnmatchedLetterPenalty(int unmatched_letter_penalty) { + this.unmatched_letter_penalty = unmatched_letter_penalty; + return this; + } + + /** + * @param text string where to search + * @return true if each character in pattern is found sequentially within text + */ + @Override + public MatchInfo match(CharSequence text) { + int idx = 0; + int idxCodepoint = 0; + int textLength = text.length(); + int[] codepoints = new int[Character.codePointCount(text, 0, textLength)]; + while (idx < textLength) { + int codepoint = Character.codePointAt(text, idx); + codepoints[idxCodepoint] = codepoint; + idx += Character.charCount(codepoint); + idxCodepoint += 1; + } + return match(codepoints); + } + + /** + * @param text string converted to codepoints + * @return true if each character in pattern is found sequentially within text + */ + @Override + public MatchInfo match(int[] text) { + // Loop variables + int score = 0; + int patternIdx = 0; + int strIdx = 0; + int strLength = text.length; + boolean fullWord = false; + boolean prevMatched = false; + boolean prevLower = false; + boolean prevSeparator = true; // true so if first letter match gets separator bonus + + // Use "best" matched letter if multiple string letters match the pattern + Integer bestLetter = null; + Integer bestLower = null; + Integer bestLetterIdx = null; + int bestLetterScore = 0; + + if (matchInfo.matchedIndices != null) { + matchInfo.matchedIndices.clear(); + } + + // Loop over strings + while (strIdx != strLength) { + Integer patternChar = null; + Integer patternLower = null; + if (patternIdx != patternLength) { + patternChar = this.patternChar[patternIdx]; + patternLower = this.patternLower[patternIdx]; + } + int strChar = text[strIdx]; + int strLower = Character.toLowerCase(strChar); + int strUpper = Character.toUpperCase(strChar); + boolean isWhitespace = Character.isWhitespace(strChar); + + boolean nextMatch = patternChar != null && patternLower == strLower; + boolean rematch = bestLetter != null && bestLower == strLower; + + boolean advanced = nextMatch && bestLetter != null; + boolean patternRepeat = bestLetter != null && patternChar != null && patternLower.equals(bestLower); + if (advanced || patternRepeat) { + score += bestLetterScore; + if (matchInfo.matchedIndices != null) { + matchInfo.matchedIndices.add(bestLetterIdx); + } + bestLetter = null; + bestLower = null; + bestLetterIdx = null; + bestLetterScore = 0; + } + + // Current char is a separator and we have matched all the previous characters, apply + // the full match bonus + if (isWhitespace && fullWord) { + score += full_word_bonus; + } + + if (nextMatch || rematch) { + int newScore = 0; + + // Apply penalty for each letter before the first pattern match + // Note: std::max because penalties are negative values. So max is smallest penalty. + if (patternIdx == 0) { + int penalty = Math.max(strIdx * leading_letter_penalty, max_leading_letter_penalty); + score += penalty; + } + + // Apply bonus for consecutive bonuses + if (prevMatched && !rematch) { + newScore += adjacency_bonus; + } + + // Apply bonus for matches after a separator + if (prevSeparator) { + newScore += separator_bonus; + } + + // Apply bonus across camel case boundaries. Includes "clever" isLetter check. + if (prevLower && strChar == strUpper && strLower != strUpper) { + newScore += camel_bonus; + } + + // Update pattern index IF the next pattern letter was matched + if (nextMatch) { + ++patternIdx; + } + + // Update best letter in text which may be for a "next" letter or a "rematch" + if (newScore >= bestLetterScore) { + + // Apply penalty for now skipped letter + if (bestLetter != null) { + score += unmatched_letter_penalty; + } + + bestLetter = strChar; + bestLower = strLower; + bestLetterIdx = strIdx; + bestLetterScore = newScore; + + if (prevSeparator) { + fullWord = true; + } + } + + prevMatched = true; + } else { + score += unmatched_letter_penalty; + prevMatched = false; + fullWord = false; + } + + // Includes "clever" isLetter check. + prevLower = strChar == strLower && strLower != strUpper; + prevSeparator = isWhitespace; + + ++strIdx; + } + + // Apply score for last match + if (bestLetter != null) { + score += bestLetterScore; + if (matchInfo.matchedIndices != null) { + matchInfo.matchedIndices.add(bestLetterIdx); + } + } + // Last word full match bonus + if (fullWord) { + score += full_word_bonus; + } + + matchInfo.match = patternIdx == patternLength; + matchInfo.score = score; + return matchInfo; + } + +} diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java new file mode 100644 index 0000000000..5ce067fc36 --- /dev/null +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java @@ -0,0 +1,43 @@ +package fr.neamar.kiss.utils.fuzzy; + +import android.util.Pair; + +import java.util.ArrayList; +import java.util.List; + +public class MatchInfo { + /** + * higher is better match. Value has no intrinsic meaning. Range varies with pattern. + * Can only compare scores with same search pattern. + */ + public int score; + public boolean match; + final ArrayList matchedIndices; + + MatchInfo() { + matchedIndices = null; + } + + MatchInfo(int patternLength) { + matchedIndices = new ArrayList<>(patternLength); + } + + public List> getMatchedSequences() { + assert this.matchedIndices != null; + // compute pair match indices + List> positions = new ArrayList<>(this.matchedIndices.size()); + int start = this.matchedIndices.get(0); + int end = start + 1; + for (int i = 1; i < this.matchedIndices.size(); i += 1) { + if (end == this.matchedIndices.get(i)) { + end += 1; + } else { + positions.add(new Pair<>(start, end)); + start = this.matchedIndices.get(i); + end = start + 1; + } + } + positions.add(new Pair<>(start, end)); + return positions; + } +} diff --git a/app/src/test/java/fr/neamar/kiss/utils/FuzzyScoreTest.java b/app/src/test/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1Test.java similarity index 93% rename from app/src/test/java/fr/neamar/kiss/utils/FuzzyScoreTest.java rename to app/src/test/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1Test.java index 0c5e10f2f9..26dbef150b 100644 --- a/app/src/test/java/fr/neamar/kiss/utils/FuzzyScoreTest.java +++ b/app/src/test/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1Test.java @@ -1,4 +1,4 @@ -package fr.neamar.kiss.utils; +package fr.neamar.kiss.utils.fuzzy; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -12,7 +12,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -class FuzzyScoreTest { +class FuzzyScoreV1Test { private static final int full_word_bonus = 1000000; private static final int adjacency_bonus = 100000; private static final int separator_bonus = 10000; @@ -53,7 +53,7 @@ private Integer doFuzzy(int[] query, int[] testString) { } private FuzzyScore createFuzzyScore(int[] query) { - return new FuzzyScore(query, false) + return new FuzzyScoreV1(query, false) .setFullWordBonus(full_word_bonus) .setAdjacencyBonus(adjacency_bonus) .setSeparatorBonus(separator_bonus) @@ -77,10 +77,10 @@ public void testReusedMatchInfoScore() { FuzzyScore fuzzyScore = createFuzzyScore(queryNormalized.codePoints); // Test full match - FuzzyScore.MatchInfo match1 = fuzzyScore.match(testStringNormalized1.codePoints); + MatchInfo match1 = fuzzyScore.match(testStringNormalized1.codePoints); assertThat(match1.score, equalTo(separator_bonus + adjacency_bonus + adjacency_bonus + full_word_bonus)); // Test no match: this must result in appropriate penalty, independent of previous match - FuzzyScore.MatchInfo match2 = fuzzyScore.match(testStringNormalized2.codePoints); + MatchInfo match2 = fuzzyScore.match(testStringNormalized2.codePoints); assertThat(match2.score, equalTo(unmatched_letter_penalty * 5)); } } From 535b494d06501a9d73b7bae25fae6bbd65c5279b Mon Sep 17 00:00:00 2001 From: marunjar Date: Sun, 15 Dec 2024 10:15:55 +0100 Subject: [PATCH 2/4] add new fuzzy search algorithm - implement v0.2.0 from https://github.com/forrestthewoods/lib_fts/blob/master/docs/fuzzy_match.md - TODO: let user choose algorithm --- .../neamar/kiss/utils/fuzzy/FuzzyScore.java | 2 + .../neamar/kiss/utils/fuzzy/FuzzyScoreV1.java | 5 + .../neamar/kiss/utils/fuzzy/FuzzyScoreV2.java | 317 ++++++++++++++++++ .../fr/neamar/kiss/utils/fuzzy/MatchInfo.java | 8 +- .../kiss/utils/fuzzy/FuzzyScoreV2Test.java | 97 ++++++ 5 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java create mode 100644 app/src/test/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2Test.java diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScore.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScore.java index 5ae61a8f55..458c16f9e4 100644 --- a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScore.java +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScore.java @@ -16,6 +16,8 @@ public interface FuzzyScore { FuzzyScore setUnmatchedLetterPenalty(int unmatched_letter_penalty); + FuzzyScore setFirstLetterBonus(int first_letter_bonus); + MatchInfo match(CharSequence text); MatchInfo match(int[] text); diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1.java index 5e0a391bcc..c6e8ae0649 100644 --- a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1.java +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1.java @@ -114,6 +114,11 @@ public FuzzyScore setUnmatchedLetterPenalty(int unmatched_letter_penalty) { return this; } + @Override + public FuzzyScore setFirstLetterBonus(int first_letter_bonus) { + return this; + } + /** * @param text string where to search * @return true if each character in pattern is found sequentially within text diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java new file mode 100644 index 0000000000..bba2da22bd --- /dev/null +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java @@ -0,0 +1,317 @@ +package fr.neamar.kiss.utils.fuzzy; + +import android.util.Pair; + +import java.util.ArrayList; +import java.util.List; + +import fr.neamar.kiss.utils.fuzzy.FuzzyScore; +import fr.neamar.kiss.utils.fuzzy.MatchInfo; + +/** + * A Sublime Text inspired fuzzy match algorithm + * https://github.com/forrestthewoods/lib_fts/blob/master/docs/fuzzy_match.md + *

+ * match("otw", "Power of the Wild", info) = true, info.score = 14 + * match("otw", "Druid of the Claw", info) = true, info.score = -3 + * match("otw", "Frostwolf Grunt", info) = true, info.score = -13 + */ +@SuppressWarnings("CanIgnoreReturnValueSuggester") +public class FuzzyScoreV2 implements FuzzyScore { + private final int patternLength; + private final int[] patternChar; + private final int[] patternLower; + /** + * bonus if all characters match (useful for short queries) + * E.g. "js" should match "js" with a higher score than "John Smith" + */ + private int full_word_bonus; + /** + * bonus for adjacent matches + */ + private int adjacency_bonus; + /** + * bonus if match occurs after a separator + */ + private int separator_bonus; + /** + * bonus if match is uppercase and prev is lower + */ + private int camel_bonus; + /** + * bonus if match is uppercase and prev is lower + */ + private int first_letter_bonus; + /** + * penalty applied for every letter in str before the first match + */ + private int leading_letter_penalty; + /** + * maximum penalty for leading letters + */ + private int max_leading_letter_penalty; + /** + * penalty for every letter that doesn't matter + */ + private int unmatched_letter_penalty; + + private final MatchInfo matchInfo; + + public FuzzyScoreV2(int[] pattern, boolean detailedMatchIndices) { + super(); + patternLength = pattern.length; + patternChar = new int[patternLength]; + patternLower = new int[patternLength]; + for (int i = 0; i < patternLower.length; i += 1) { + patternChar[i] = pattern[i]; + patternLower[i] = Character.toLowerCase(pattern[i]); + } + full_word_bonus = 100; + adjacency_bonus = 10; + separator_bonus = 5; + camel_bonus = 10; + first_letter_bonus = 5; + leading_letter_penalty = -3; + max_leading_letter_penalty = -9; + unmatched_letter_penalty = -1; + if (detailedMatchIndices) { + matchInfo = new MatchInfo(patternLength); + } else { + matchInfo = new MatchInfo(); + } + } + + public FuzzyScoreV2(int[] pattern) { + this(pattern, false); + } + + @Override + public FuzzyScore setFullWordBonus(int full_word_bonus) { + this.full_word_bonus = full_word_bonus; + return this; + } + + @Override + public FuzzyScore setAdjacencyBonus(int adjacency_bonus) { + this.adjacency_bonus = adjacency_bonus; + return this; + } + + @Override + public FuzzyScore setSeparatorBonus(int separator_bonus) { + this.separator_bonus = separator_bonus; + return this; + } + + @Override + public FuzzyScore setCamelBonus(int camel_bonus) { + this.camel_bonus = camel_bonus; + return this; + } + + @Override + public FuzzyScore setFirstLetterBonus(int first_letter_bonus) { + this.first_letter_bonus = first_letter_bonus; + return this; + } + + @Override + public FuzzyScore setLeadingLetterPenalty(int leading_letter_penalty) { + this.leading_letter_penalty = leading_letter_penalty; + return this; + } + + @Override + public FuzzyScore setMaxLeadingLetterPenalty(int max_leading_letter_penalty) { + this.max_leading_letter_penalty = max_leading_letter_penalty; + return this; + } + + @Override + public FuzzyScore setUnmatchedLetterPenalty(int unmatched_letter_penalty) { + this.unmatched_letter_penalty = unmatched_letter_penalty; + return this; + } + + /** + * @param text string where to search + * @return true if each character in pattern is found sequentially within text + */ + @Override + public MatchInfo match(CharSequence text) { + int idx = 0; + int idxCodepoint = 0; + int textLength = text.length(); + int[] codepoints = new int[Character.codePointCount(text, 0, textLength)]; + while (idx < textLength) { + int codepoint = Character.codePointAt(text, idx); + codepoints[idxCodepoint] = codepoint; + idx += Character.charCount(codepoint); + idxCodepoint += 1; + } + return match(codepoints); + } + + /** + * @param str string converted to codepoints + * @return true if each character in pattern is found sequentially within text + */ + @Override + public MatchInfo match(int[] str) { + int recursionCount = 0; + int recursionLimit = 10; + int maxMatches = Math.min(patternLength, str.length); + List matches = new ArrayList<>(); + + MatchInfo matchInfo = matchRecursive( + str, + 0 /* patternCurIndex */, + 0 /* strCurrIndex */, + null /* srcMatches */, + matches, + maxMatches, + 0 /* nextMatch */, + recursionCount, + recursionLimit + ); + this.matchInfo.score = matchInfo.score; + this.matchInfo.match = matchInfo.match; + if (this.matchInfo.matchedIndices != null) { + this.matchInfo.matchedIndices.addAll(matches); + } + return this.matchInfo; + } + + private MatchInfo matchRecursive( + int[] str, + int patternCurIndex, + int strCurrIndex, + List srcMatches, + List matches, + int maxMatches, + int nextMatch, + int recursionCount, + int recursionLimit + ) { + int outScore = 0; + + // Return if recursion limit is reached. + if (++recursionCount >= recursionLimit) { + return new MatchInfo(false, outScore); + } + + // Return if we reached ends of strings. + if (patternCurIndex == patternLength || strCurrIndex == str.length) { + return new MatchInfo(false, outScore); + } + + // Recursion params + boolean recursiveMatch = false; + List bestRecursiveMatches = new ArrayList<>(); + int bestRecursiveScore = 0; + + // Loop through pattern and str looking for a match. + boolean firstMatch = true; + while (patternCurIndex < patternLength && strCurrIndex < str.length) { + // Match found. + if (patternLower[patternCurIndex] == Character.toLowerCase(str[strCurrIndex])) { + if (nextMatch >= maxMatches) { + return new MatchInfo(false, outScore); + } + + if (firstMatch && srcMatches != null) { + matches.clear(); + matches.addAll(srcMatches); + firstMatch = false; + } + + List recursiveMatches = new ArrayList<>(); + MatchInfo recursiveResult = matchRecursive( + str, + patternCurIndex, + strCurrIndex + 1, + matches, + recursiveMatches, + maxMatches, + nextMatch, + recursionCount, + recursionLimit + ); + + if (recursiveResult.match) { + // Pick best recursive score. + if (!recursiveMatch || recursiveResult.score > bestRecursiveScore) { + bestRecursiveMatches.clear(); + bestRecursiveMatches.addAll(recursiveMatches); + bestRecursiveScore = recursiveResult.score; + } + recursiveMatch = true; + } + + matches.add(strCurrIndex); + ++patternCurIndex; + } + ++strCurrIndex; + } + + boolean matched = patternCurIndex == patternLength; + + if (matched) { + outScore = 100; + + // Apply leading letter penalty + int penalty = Math.max(max_leading_letter_penalty, leading_letter_penalty * matches.get(0)); + outScore += penalty; + + //Apply unmatched penalty + int unmatched = str.length - nextMatch; + outScore += unmatched_letter_penalty * unmatched; + + // Apply ordering bonuses + for (int i = 0; i < matches.size(); i++) { + int currIdx = matches.get(i); + + if (i > 0) { + int prevIdx = matches.get(i - 1); + if (currIdx == prevIdx + 1) { + outScore += adjacency_bonus; + } + } + + // Check for bonuses based on neighbor character value. + if (currIdx > 0) { + // Camel case + int neighbor = str[currIdx - 1]; + int curr = str[currIdx]; + if ( + neighbor != Character.toUpperCase(neighbor) && + curr != Character.toLowerCase(curr) + ) { + outScore += camel_bonus; + } + boolean isNeighbourSeparator = Character.isWhitespace(neighbor); + if (isNeighbourSeparator) { + outScore += separator_bonus; + } + } else { + // First letter + outScore += first_letter_bonus; + } + } + } + // Return best result + if (recursiveMatch && (!matched || bestRecursiveScore > outScore)) { + // Recursive score is better than "this" + matches.clear(); + matches.addAll(bestRecursiveMatches); + outScore = bestRecursiveScore; + return new MatchInfo(true, outScore); + } else if (matched) { + // "this" score is better than recursive + return new MatchInfo(true, outScore); + } else { + return new MatchInfo(false, outScore); + } + } + +} diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java index 5ce067fc36..f35a34392b 100644 --- a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java @@ -12,7 +12,13 @@ public class MatchInfo { */ public int score; public boolean match; - final ArrayList matchedIndices; + final List matchedIndices; + + MatchInfo(boolean match, int score) { + this(); + this.match = match; + this.score = score; + } MatchInfo() { matchedIndices = null; diff --git a/app/src/test/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2Test.java b/app/src/test/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2Test.java new file mode 100644 index 0000000000..294f0d6351 --- /dev/null +++ b/app/src/test/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2Test.java @@ -0,0 +1,97 @@ +package fr.neamar.kiss.utils.fuzzy; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import fr.neamar.kiss.normalizer.StringNormalizer; + +class FuzzyScoreV2Test { + private static final int full_word_bonus = 0;//1000000; + private static final int adjacency_bonus = 100000; + private static final int separator_bonus = 10000; + private static final int camel_bonus = 1000; + private static final int first_letter_bonus = 100; + private static final int leading_letter_penalty = -100; + private static final int max_leading_letter_penalty = -300; + private static final int unmatched_letter_penalty = -1; + + @ParameterizedTest + @MethodSource("testProvider") + public void testOperations(String query, String testString, int score, boolean match) { + StringNormalizer.Result queryNormalized = StringNormalizer.normalizeWithResult(query, false); + StringNormalizer.Result testStringNormalized = StringNormalizer.normalizeWithResult(testString, false); + + MatchInfo result = doFuzzy(queryNormalized.codePoints, testStringNormalized.codePoints); + assertThat(result.match, equalTo(match)); + assertThat(result.score, equalTo(score)); + } + + @SuppressWarnings("unused") + private static Stream testProvider() { + return Stream.of( + Arguments.of("no match", "some string", max_leading_letter_penalty + 10 * unmatched_letter_penalty, false), + Arguments.of("yt", "YouTube", separator_bonus + camel_bonus + 5 * unmatched_letter_penalty, true), + Arguments.of("js", "js", full_word_bonus + adjacency_bonus + separator_bonus, true), + + // Test full match start of word + Arguments.of("js", "js end", full_word_bonus + adjacency_bonus + separator_bonus + 4 * unmatched_letter_penalty, true), + // Test full match end of word + Arguments.of("js", "start js", full_word_bonus + adjacency_bonus + separator_bonus + 6 * unmatched_letter_penalty + max_leading_letter_penalty, true), + + Arguments.of("js", "John Smith", 2 * separator_bonus + 8 * unmatched_letter_penalty, true), + Arguments.of("jsmith", "John Smith", 2 * separator_bonus + 4 * unmatched_letter_penalty + 4 * adjacency_bonus + full_word_bonus, true), + + Arguments.of("second", "first second third word", separator_bonus + 15 * unmatched_letter_penalty + 5 * adjacency_bonus + full_word_bonus + 3 * leading_letter_penalty, true), + Arguments.of("econd", "first second third word", 16 * unmatched_letter_penalty + 4 * adjacency_bonus + max_leading_letter_penalty, true), + Arguments.of("third", "first second third word", separator_bonus + 17 * unmatched_letter_penalty + 4 * adjacency_bonus + full_word_bonus + max_leading_letter_penalty, true), + Arguments.of("word", "first second third word", separator_bonus + 19 * unmatched_letter_penalty + 3 * adjacency_bonus + full_word_bonus + max_leading_letter_penalty, true), + Arguments.of("first second third word", "firss", separator_bonus + 3 * adjacency_bonus + full_word_bonus, false) + ); + } + + private MatchInfo doFuzzy(int[] query, int[] testString) { + return createFuzzyScore(query) + .match(testString); + } + + private FuzzyScore createFuzzyScore(int[] query) { + return new FuzzyScoreV2(query, true) + .setFullWordBonus(full_word_bonus) + .setAdjacencyBonus(adjacency_bonus) + .setSeparatorBonus(separator_bonus) + .setCamelBonus(camel_bonus) + .setFirstLetterBonus(first_letter_bonus) + .setLeadingLetterPenalty(leading_letter_penalty) + .setMaxLeadingLetterPenalty(max_leading_letter_penalty) + .setUnmatchedLetterPenalty(unmatched_letter_penalty); + } + + @Test + public void testReusedMatchInfoScore() { + StringNormalizer.Result queryNormalized = StringNormalizer.normalizeWithResult("Bob", false); + StringNormalizer.Result testStringNormalized1 = StringNormalizer.normalizeWithResult("Bob", false); + StringNormalizer.Result testStringNormalized2 = StringNormalizer.normalizeWithResult("Alice", false); + + // Test full match standalone + assertThat(doFuzzy(queryNormalized.codePoints, testStringNormalized1.codePoints).score, equalTo(separator_bonus + adjacency_bonus + adjacency_bonus + full_word_bonus)); + // Test no match standalone: this must result in appropriate penalty + assertThat(doFuzzy(queryNormalized.codePoints, testStringNormalized2.codePoints).score, equalTo(unmatched_letter_penalty * 5)); + + // create fuzzy score that is reused as in KISS providers + FuzzyScore fuzzyScore = createFuzzyScore(queryNormalized.codePoints); + + // Test full match + MatchInfo match1 = fuzzyScore.match(testStringNormalized1.codePoints); + assertThat(match1.score, equalTo(separator_bonus + adjacency_bonus + adjacency_bonus + full_word_bonus)); + // Test no match: this must result in appropriate penalty, independent of previous match + MatchInfo match2 = fuzzyScore.match(testStringNormalized2.codePoints); + assertThat(match2.score, equalTo(unmatched_letter_penalty * 5)); + } +} From f86783692720ddb923e5dcde1ff55c3263b30890 Mon Sep 17 00:00:00 2001 From: marunjar Date: Wed, 18 Dec 2024 22:40:45 +0100 Subject: [PATCH 3/4] use new fuzzy search algorithm - add advanced setting for testing new algorithm - fix algorithm (bonus, wrong index, highlighting) - fix tests --- .../java/fr/neamar/kiss/CustomIconDialog.java | 4 +- .../neamar/kiss/utils/fuzzy/FuzzyFactory.java | 9 +++- .../neamar/kiss/utils/fuzzy/FuzzyScoreV1.java | 9 +--- .../neamar/kiss/utils/fuzzy/FuzzyScoreV2.java | 50 ++++++------------- .../fr/neamar/kiss/utils/fuzzy/MatchInfo.java | 2 + app/src/main/res/xml/preferences.xml | 4 ++ .../kiss/utils/fuzzy/FuzzyScoreV2Test.java | 46 +++++++++-------- 7 files changed, 59 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/fr/neamar/kiss/CustomIconDialog.java b/app/src/main/java/fr/neamar/kiss/CustomIconDialog.java index b7a3995fe3..31a4b4239a 100644 --- a/app/src/main/java/fr/neamar/kiss/CustomIconDialog.java +++ b/app/src/main/java/fr/neamar/kiss/CustomIconDialog.java @@ -309,13 +309,13 @@ private void refreshList() { IconsHandler iconsHandler = KissApplication.getApplication(getActivity()).getIconsHandler(); IconPackXML iconPack = iconsHandler.getCustomIconPack(); if (iconPack != null) { - Collection drawables = ((IconPackXML) iconPack).getDrawableList(); + Collection drawables = iconPack.getDrawableList(); if (drawables != null) { StringNormalizer.Result normalized = StringNormalizer.normalizeWithResult(mSearch.getText(), true); FuzzyScore fuzzyScore = FuzzyFactory.createFuzzyScore(getActivity(), normalized.codePoints); for (IconPackXML.DrawableInfo info : drawables) { if (fuzzyScore.match(info.getDrawableName()).match) - mIconData.add(new IconData((IconPackXML) iconPack, info)); + mIconData.add(new IconData(iconPack, info)); } } } diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyFactory.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyFactory.java index d104915271..b2409cc8a4 100644 --- a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyFactory.java +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyFactory.java @@ -1,6 +1,8 @@ package fr.neamar.kiss.utils.fuzzy; import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; import androidx.annotation.NonNull; @@ -11,7 +13,12 @@ public static FuzzyScore createFuzzyScore(@NonNull Context context, int[] patter } public static FuzzyScore createFuzzyScore(@NonNull Context context, int[] pattern, boolean detailedMatchIndices) { - return new FuzzyScoreV1(pattern, detailedMatchIndices); + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (prefs.getBoolean("use-fuzzy-score-v2", false)) { + return new FuzzyScoreV2(pattern, detailedMatchIndices); + } else { + return new FuzzyScoreV1(pattern, detailedMatchIndices); + } } } diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1.java index c6e8ae0649..d21d1e6137 100644 --- a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1.java +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV1.java @@ -8,7 +8,6 @@ * match("otw", "Druid of the Claw", info) = true, info.score = -3 * match("otw", "Frostwolf Grunt", info) = true, info.score = -13 */ -@SuppressWarnings("CanIgnoreReturnValueSuggester") public class FuzzyScoreV1 implements FuzzyScore { private final int patternLength; private final int[] patternChar; @@ -68,10 +67,6 @@ public FuzzyScoreV1(int[] pattern, boolean detailedMatchIndices) { } } - public FuzzyScoreV1(int[] pattern) { - this(pattern, false); - } - @Override public FuzzyScore setFullWordBonus(int full_word_bonus) { this.full_word_bonus = full_word_bonus; @@ -121,7 +116,7 @@ public FuzzyScore setFirstLetterBonus(int first_letter_bonus) { /** * @param text string where to search - * @return true if each character in pattern is found sequentially within text + * @return {@link MatchInfo}, with match set to true if each character in pattern is found sequentially within text */ @Override public MatchInfo match(CharSequence text) { @@ -140,7 +135,7 @@ public MatchInfo match(CharSequence text) { /** * @param text string converted to codepoints - * @return true if each character in pattern is found sequentially within text + * @return {@link MatchInfo}, with match set to true if each character in pattern is found sequentially within text */ @Override public MatchInfo match(int[] text) { diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java index bba2da22bd..bde718c9e9 100644 --- a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java @@ -1,13 +1,8 @@ package fr.neamar.kiss.utils.fuzzy; -import android.util.Pair; - import java.util.ArrayList; import java.util.List; -import fr.neamar.kiss.utils.fuzzy.FuzzyScore; -import fr.neamar.kiss.utils.fuzzy.MatchInfo; - /** * A Sublime Text inspired fuzzy match algorithm * https://github.com/forrestthewoods/lib_fts/blob/master/docs/fuzzy_match.md @@ -16,16 +11,10 @@ * match("otw", "Druid of the Claw", info) = true, info.score = -3 * match("otw", "Frostwolf Grunt", info) = true, info.score = -13 */ -@SuppressWarnings("CanIgnoreReturnValueSuggester") public class FuzzyScoreV2 implements FuzzyScore { private final int patternLength; - private final int[] patternChar; private final int[] patternLower; - /** - * bonus if all characters match (useful for short queries) - * E.g. "js" should match "js" with a higher score than "John Smith" - */ - private int full_word_bonus; + /** * bonus for adjacent matches */ @@ -60,19 +49,16 @@ public class FuzzyScoreV2 implements FuzzyScore { public FuzzyScoreV2(int[] pattern, boolean detailedMatchIndices) { super(); patternLength = pattern.length; - patternChar = new int[patternLength]; patternLower = new int[patternLength]; for (int i = 0; i < patternLower.length; i += 1) { - patternChar[i] = pattern[i]; patternLower[i] = Character.toLowerCase(pattern[i]); } - full_word_bonus = 100; - adjacency_bonus = 10; - separator_bonus = 5; - camel_bonus = 10; - first_letter_bonus = 5; - leading_letter_penalty = -3; - max_leading_letter_penalty = -9; + adjacency_bonus = 15; + separator_bonus = 30; + camel_bonus = 30; + first_letter_bonus = 15; + leading_letter_penalty = -5; + max_leading_letter_penalty = -15; unmatched_letter_penalty = -1; if (detailedMatchIndices) { matchInfo = new MatchInfo(patternLength); @@ -81,13 +67,8 @@ public FuzzyScoreV2(int[] pattern, boolean detailedMatchIndices) { } } - public FuzzyScoreV2(int[] pattern) { - this(pattern, false); - } - @Override public FuzzyScore setFullWordBonus(int full_word_bonus) { - this.full_word_bonus = full_word_bonus; return this; } @@ -135,7 +116,7 @@ public FuzzyScore setUnmatchedLetterPenalty(int unmatched_letter_penalty) { /** * @param text string where to search - * @return true if each character in pattern is found sequentially within text + * @return {@link MatchInfo}, with match set to true if each character in pattern is found sequentially within text */ @Override public MatchInfo match(CharSequence text) { @@ -154,7 +135,7 @@ public MatchInfo match(CharSequence text) { /** * @param str string converted to codepoints - * @return true if each character in pattern is found sequentially within text + * @return {@link MatchInfo}, with match set to true if each character in pattern is found sequentially within text */ @Override public MatchInfo match(int[] str) { @@ -177,6 +158,7 @@ public MatchInfo match(int[] str) { this.matchInfo.score = matchInfo.score; this.matchInfo.match = matchInfo.match; if (this.matchInfo.matchedIndices != null) { + this.matchInfo.matchedIndices.clear(); this.matchInfo.matchedIndices.addAll(matches); } return this.matchInfo; @@ -193,16 +175,14 @@ private MatchInfo matchRecursive( int recursionCount, int recursionLimit ) { - int outScore = 0; - // Return if recursion limit is reached. if (++recursionCount >= recursionLimit) { - return new MatchInfo(false, outScore); + return MatchInfo.UNMATCHED; } // Return if we reached ends of strings. if (patternCurIndex == patternLength || strCurrIndex == str.length) { - return new MatchInfo(false, outScore); + return MatchInfo.UNMATCHED; } // Recursion params @@ -216,7 +196,7 @@ private MatchInfo matchRecursive( // Match found. if (patternLower[patternCurIndex] == Character.toLowerCase(str[strCurrIndex])) { if (nextMatch >= maxMatches) { - return new MatchInfo(false, outScore); + return MatchInfo.UNMATCHED; } if (firstMatch && srcMatches != null) { @@ -249,11 +229,13 @@ private MatchInfo matchRecursive( } matches.add(strCurrIndex); + ++nextMatch; ++patternCurIndex; } ++strCurrIndex; } + int outScore = 0; boolean matched = patternCurIndex == patternLength; if (matched) { @@ -310,7 +292,7 @@ private MatchInfo matchRecursive( // "this" score is better than recursive return new MatchInfo(true, outScore); } else { - return new MatchInfo(false, outScore); + return MatchInfo.UNMATCHED; } } diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java index f35a34392b..82557ffaa0 100644 --- a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/MatchInfo.java @@ -6,6 +6,8 @@ import java.util.List; public class MatchInfo { + public static final MatchInfo UNMATCHED = new MatchInfo(false, 0); + /** * higher is better match. Value has no intrinsic meaning. Range varies with pattern. * Can only compare scores with same search pattern. diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 9ff1876266..fd9f88e3f3 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -525,6 +525,10 @@ android:order="2" android:summary="@string/contact_mime_types_summary" android:title="@string/contact_mime_types_title" /> + testProvider() { return Stream.of( - Arguments.of("no match", "some string", max_leading_letter_penalty + 10 * unmatched_letter_penalty, false), - Arguments.of("yt", "YouTube", separator_bonus + camel_bonus + 5 * unmatched_letter_penalty, true), - Arguments.of("js", "js", full_word_bonus + adjacency_bonus + separator_bonus, true), + Arguments.of("no match", "some string", 0, false), + Arguments.of("yt", "YouTube", 100 + camel_bonus + first_letter_bonus + 5 * unmatched_letter_penalty, true), + Arguments.of("js", "js", 100 + adjacency_bonus + first_letter_bonus, true), // Test full match start of word - Arguments.of("js", "js end", full_word_bonus + adjacency_bonus + separator_bonus + 4 * unmatched_letter_penalty, true), + Arguments.of("js", "js end", 100 + adjacency_bonus + first_letter_bonus + 4 * unmatched_letter_penalty, true), // Test full match end of word - Arguments.of("js", "start js", full_word_bonus + adjacency_bonus + separator_bonus + 6 * unmatched_letter_penalty + max_leading_letter_penalty, true), + Arguments.of("js", "start js", 100 + adjacency_bonus + separator_bonus + 6 * unmatched_letter_penalty + max_leading_letter_penalty, true), - Arguments.of("js", "John Smith", 2 * separator_bonus + 8 * unmatched_letter_penalty, true), - Arguments.of("jsmith", "John Smith", 2 * separator_bonus + 4 * unmatched_letter_penalty + 4 * adjacency_bonus + full_word_bonus, true), + Arguments.of("js", "John Smith", 100 + separator_bonus + first_letter_bonus + 8 * unmatched_letter_penalty, true), + Arguments.of("jsmith", "John Smith", 100 + 4 * adjacency_bonus + separator_bonus + first_letter_bonus + 4 * unmatched_letter_penalty, true), - Arguments.of("second", "first second third word", separator_bonus + 15 * unmatched_letter_penalty + 5 * adjacency_bonus + full_word_bonus + 3 * leading_letter_penalty, true), - Arguments.of("econd", "first second third word", 16 * unmatched_letter_penalty + 4 * adjacency_bonus + max_leading_letter_penalty, true), - Arguments.of("third", "first second third word", separator_bonus + 17 * unmatched_letter_penalty + 4 * adjacency_bonus + full_word_bonus + max_leading_letter_penalty, true), - Arguments.of("word", "first second third word", separator_bonus + 19 * unmatched_letter_penalty + 3 * adjacency_bonus + full_word_bonus + max_leading_letter_penalty, true), - Arguments.of("first second third word", "firss", separator_bonus + 3 * adjacency_bonus + full_word_bonus, false) + Arguments.of("second", "first second third word", 100 + 5 * adjacency_bonus + separator_bonus + 17 * unmatched_letter_penalty + 3 * leading_letter_penalty, true), + Arguments.of("econd", "first second third word", 100 + 4 * adjacency_bonus + 18 * unmatched_letter_penalty + max_leading_letter_penalty, true), + Arguments.of("third", "first second third word", 100 + 4 * adjacency_bonus + separator_bonus + 18 * unmatched_letter_penalty + max_leading_letter_penalty, true), + Arguments.of("word", "first second third word", 100 + 3 * adjacency_bonus + separator_bonus + 19 * unmatched_letter_penalty + max_leading_letter_penalty, true), + Arguments.of("first second third word", "firss", 0, false) ); } @@ -63,7 +62,6 @@ private MatchInfo doFuzzy(int[] query, int[] testString) { private FuzzyScore createFuzzyScore(int[] query) { return new FuzzyScoreV2(query, true) - .setFullWordBonus(full_word_bonus) .setAdjacencyBonus(adjacency_bonus) .setSeparatorBonus(separator_bonus) .setCamelBonus(camel_bonus) @@ -80,18 +78,24 @@ public void testReusedMatchInfoScore() { StringNormalizer.Result testStringNormalized2 = StringNormalizer.normalizeWithResult("Alice", false); // Test full match standalone - assertThat(doFuzzy(queryNormalized.codePoints, testStringNormalized1.codePoints).score, equalTo(separator_bonus + adjacency_bonus + adjacency_bonus + full_word_bonus)); + MatchInfo match = doFuzzy(queryNormalized.codePoints, testStringNormalized1.codePoints); + assertThat(match.match, equalTo(true)); + assertThat(match.score, equalTo(100 + 2 * adjacency_bonus + first_letter_bonus)); // Test no match standalone: this must result in appropriate penalty - assertThat(doFuzzy(queryNormalized.codePoints, testStringNormalized2.codePoints).score, equalTo(unmatched_letter_penalty * 5)); + match = doFuzzy(queryNormalized.codePoints, testStringNormalized2.codePoints); + assertThat(match.match, equalTo(false)); + assertThat(match.score, equalTo(0)); // create fuzzy score that is reused as in KISS providers FuzzyScore fuzzyScore = createFuzzyScore(queryNormalized.codePoints); // Test full match - MatchInfo match1 = fuzzyScore.match(testStringNormalized1.codePoints); - assertThat(match1.score, equalTo(separator_bonus + adjacency_bonus + adjacency_bonus + full_word_bonus)); + match = fuzzyScore.match(testStringNormalized1.codePoints); + assertThat(match.match, equalTo(true)); + assertThat(match.score, equalTo(100 + 2 * adjacency_bonus + first_letter_bonus)); // Test no match: this must result in appropriate penalty, independent of previous match - MatchInfo match2 = fuzzyScore.match(testStringNormalized2.codePoints); - assertThat(match2.score, equalTo(unmatched_letter_penalty * 5)); + match = fuzzyScore.match(testStringNormalized2.codePoints); + assertThat(match.match, equalTo(false)); + assertThat(match.score, equalTo(0)); } } From e30222344592c06af377d7df82510bea0374a76c Mon Sep 17 00:00:00 2001 From: marunjar Date: Mon, 27 Jan 2025 22:19:39 +0100 Subject: [PATCH 4/4] tune fuzzy algorithm increase penalties: - double max_leading_letter_penalty - double unmatched_letter_penalty --- .../main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java index bde718c9e9..2e5525e0a6 100644 --- a/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java +++ b/app/src/main/java/fr/neamar/kiss/utils/fuzzy/FuzzyScoreV2.java @@ -58,8 +58,8 @@ public FuzzyScoreV2(int[] pattern, boolean detailedMatchIndices) { camel_bonus = 30; first_letter_bonus = 15; leading_letter_penalty = -5; - max_leading_letter_penalty = -15; - unmatched_letter_penalty = -1; + max_leading_letter_penalty = -30; + unmatched_letter_penalty = -2; if (detailedMatchIndices) { matchInfo = new MatchInfo(patternLength); } else {