Merge pull request #39 from jjw24/make_fuzzysearch_userfriendly

Make fuzzy search user friendly
This commit is contained in:
Jeremy Wu 2019-09-30 21:55:37 +10:00 committed by GitHub
commit 8cdb5fe346
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 347 additions and 134 deletions

View file

@ -40,9 +40,8 @@ namespace Wox.Plugin.BrowserBookmark
if (!topResults)
{
// Since we mixed chrome and firefox bookmarks, we should order them again
var fuzzyMatcher = FuzzyMatcher.Create(param);
returnList = cachedBookmarks.Where(o => MatchProgram(o, fuzzyMatcher)).ToList();
// Since we mixed chrome and firefox bookmarks, we should order them again
returnList = cachedBookmarks.Where(o => MatchProgram(o, param)).ToList();
returnList = returnList.OrderByDescending(o => o.Score).ToList();
}
@ -61,11 +60,11 @@ namespace Wox.Plugin.BrowserBookmark
}).ToList();
}
private bool MatchProgram(Bookmark bookmark, FuzzyMatcher matcher)
private bool MatchProgram(Bookmark bookmark, string queryString)
{
if ((bookmark.Score = matcher.Evaluate(bookmark.Name).Score) > 0) return true;
if ((bookmark.Score = matcher.Evaluate(bookmark.PinyinName).Score) > 0) return true;
if ((bookmark.Score = matcher.Evaluate(bookmark.Url).Score / 10) > 0) return true;
if (StringMatcher.FuzzySearch(queryString, bookmark.Name, new MatchOption()).IsSearchPrecisionScoreMet()) return true;
if ( StringMatcher.FuzzySearch(queryString, bookmark.PinyinName, new MatchOption()).IsSearchPrecisionScoreMet()) return true;
if (StringMatcher.FuzzySearch(queryString, bookmark.Url, new MatchOption()).IsSearchPrecisionScoreMet()) return true;
return false;
}

View file

@ -79,14 +79,14 @@ namespace Wox.Plugin.ControlPanel
var scores = new List<int> {0};
if (string.IsNullOrEmpty(item.LocalizedString))
{
var score1 = StringMatcher.Score(item.LocalizedString, query);
var score1 = StringMatcher.FuzzySearch(query, item.LocalizedString).ScoreAfterSearchPrecisionFilter();
var score2 = StringMatcher.ScoreForPinyin(item.LocalizedString, query);
scores.Add(score1);
scores.Add(score2);
}
if (!string.IsNullOrEmpty(item.InfoTip))
{
var score1 = StringMatcher.Score(item.InfoTip, query);
var score1 = StringMatcher.FuzzySearch(query, item.InfoTip).ScoreAfterSearchPrecisionFilter();
var score2 = StringMatcher.ScoreForPinyin(item.InfoTip, query);
scores.Add(score1);
scores.Add(score2);

View file

@ -240,9 +240,9 @@ namespace Wox.Plugin.Program.Programs
private int Score(string query)
{
var score1 = StringMatcher.Score(DisplayName, query);
var score1 = StringMatcher.FuzzySearch(query, DisplayName).ScoreAfterSearchPrecisionFilter();
var score2 = StringMatcher.ScoreForPinyin(DisplayName, query);
var score3 = StringMatcher.Score(Description, query);
var score3 = StringMatcher.FuzzySearch(query, Description).ScoreAfterSearchPrecisionFilter();
var score4 = StringMatcher.ScoreForPinyin(Description, query);
var score = new[] { score1, score2, score3, score4 }.Max();
return score;

View file

@ -31,11 +31,11 @@ namespace Wox.Plugin.Program.Programs
private int Score(string query)
{
var score1 = StringMatcher.Score(Name, query);
var score1 = StringMatcher.FuzzySearch(query, Name).ScoreAfterSearchPrecisionFilter();
var score2 = StringMatcher.ScoreForPinyin(Name, query);
var score3 = StringMatcher.Score(Description, query);
var score3 = StringMatcher.FuzzySearch(query, Description).ScoreAfterSearchPrecisionFilter();
var score4 = StringMatcher.ScoreForPinyin(Description, query);
var score5 = StringMatcher.Score(ExecutableName, query);
var score5 = StringMatcher.FuzzySearch(query, ExecutableName).ScoreAfterSearchPrecisionFilter();
var score = new[] { score1, score2, score3, score4, score5 }.Max();
return score;
}

View file

@ -56,8 +56,8 @@ namespace Wox.Plugin.Sys
var results = new List<Result>();
foreach (var c in commands)
{
var titleScore = StringMatcher.Score(c.Title, query.Search);
var subTitleScore = StringMatcher.Score(c.SubTitle, query.Search);
var titleScore = StringMatcher.FuzzySearch(query.Search, c.Title).ScoreAfterSearchPrecisionFilter();
var subTitleScore = StringMatcher.FuzzySearch(query.Search, c.SubTitle).ScoreAfterSearchPrecisionFilter();
var score = Math.Max(titleScore, subTitleScore);
if (score > 0)
{

View file

@ -1,10 +1,8 @@
using System.Text;
using System;
namespace Wox.Infrastructure
{
/// <summary>
/// refer to https://github.com/mattyork/fuzzy
/// </summary>
[Obsolete("This class is obsolete and should not be used. Please use the static function StringMatcher.FuzzySearch")]
public class FuzzyMatcher
{
private string query;
@ -28,99 +26,7 @@ namespace Wox.Infrastructure
public MatchResult Evaluate(string str)
{
if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(query)) return new MatchResult { Success = false };
var len = str.Length;
var compareString = opt.IgnoreCase ? str.ToLower() : str;
var pattern = opt.IgnoreCase ? query.ToLower() : query;
var sb = new StringBuilder(str.Length + (query.Length * (opt.Prefix.Length + opt.Suffix.Length)));
var patternIdx = 0;
var firstMatchIndex = -1;
var lastMatchIndex = 0;
char ch;
for (var idx = 0; idx < len; idx++)
{
ch = str[idx];
if (compareString[idx] == pattern[patternIdx])
{
if (firstMatchIndex < 0)
firstMatchIndex = idx;
lastMatchIndex = idx + 1;
sb.Append(opt.Prefix + ch + opt.Suffix);
patternIdx += 1;
}
else
{
sb.Append(ch);
}
// match success, append remain char
if (patternIdx == pattern.Length && (idx + 1) != compareString.Length)
{
sb.Append(str.Substring(idx + 1));
break;
}
}
// return rendered string if we have a match for every char
if (patternIdx == pattern.Length)
{
return new MatchResult
{
Success = true,
Value = sb.ToString(),
Score = CalScore(str, firstMatchIndex, lastMatchIndex - firstMatchIndex)
};
}
return new MatchResult { Success = false };
return StringMatcher.FuzzySearch(query, str, opt);
}
private int CalScore(string str, int firstIndex, int matchLen)
{
//a match found near the beginning of a string is scored more than a match found near the end
//a match is scored more if the characters in the patterns are closer to each other, while the score is lower if they are more spread out
var score = 100 * (query.Length + 1) / ((1 + firstIndex) + (matchLen + 1));
//a match with less characters assigning more weights
if (str.Length - query.Length < 5)
score = score + 20;
else if (str.Length - query.Length < 10)
score = score + 10;
return score;
}
}
public class MatchResult
{
public bool Success { get; set; }
public int Score { get; set; }
/// <summary>
/// hightlight string
/// </summary>
public string Value { get; set; }
}
public class MatchOption
{
public MatchOption()
{
Prefix = "";
Suffix = "";
IgnoreCase = true;
}
/// <summary>
/// prefix of match char, use for hightlight
/// </summary>
public string Prefix { get; set; }
/// <summary>
/// suffix of match char, use for hightlight
/// </summary>
public string Suffix { get; set; }
public bool IgnoreCase { get; set; }
}
}

View file

@ -1,20 +1,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Wox.Infrastructure;
using System.Text;
using Wox.Infrastructure.Logger;
using Wox.Infrastructure.UserSettings;
namespace Wox.Infrastructure
{
public static class StringMatcher
{
public static string UserSettingSearchPrecision { get; set; }
[Obsolete("This method is obsolete and should not be used. Please use the static function StringMatcher.FuzzySearch")]
public static int Score(string source, string target)
{
if (!string.IsNullOrEmpty(source) && !string.IsNullOrEmpty(target))
{
FuzzyMatcher matcher = FuzzyMatcher.Create(target);
var score = matcher.Evaluate(source).Score;
return score;
return FuzzySearch(target, source, new MatchOption()).Score;
}
else
{
@ -22,6 +23,108 @@ namespace Wox.Infrastructure
}
}
[Obsolete("This method is obsolete and should not be used. Please use the static function StringMatcher.FuzzySearch")]
public static bool IsMatch(string source, string target)
{
return FuzzySearch(target, source, new MatchOption()).Score > 0;
}
public static MatchResult FuzzySearch(string query, string stringToCompare)
{
return FuzzySearch(query, stringToCompare, new MatchOption());
}
/// <summary>
/// refer to https://github.com/mattyork/fuzzy
/// </summary>
public static MatchResult FuzzySearch(string query, string stringToCompare, MatchOption opt)
{
if (string.IsNullOrEmpty(stringToCompare) || string.IsNullOrEmpty(query)) return new MatchResult { Success = false };
query.Trim();
var len = stringToCompare.Length;
var compareString = opt.IgnoreCase ? stringToCompare.ToLower() : stringToCompare;
var pattern = opt.IgnoreCase ? query.ToLower() : query;
var sb = new StringBuilder(stringToCompare.Length + (query.Length * (opt.Prefix.Length + opt.Suffix.Length)));
var patternIdx = 0;
var firstMatchIndex = -1;
var lastMatchIndex = 0;
char ch;
for (var idx = 0; idx < len; idx++)
{
ch = stringToCompare[idx];
if (compareString[idx] == pattern[patternIdx])
{
if (firstMatchIndex < 0)
firstMatchIndex = idx;
lastMatchIndex = idx + 1;
sb.Append(opt.Prefix + ch + opt.Suffix);
patternIdx += 1;
}
else
{
sb.Append(ch);
}
// match success, append remain char
if (patternIdx == pattern.Length && (idx + 1) != compareString.Length)
{
sb.Append(stringToCompare.Substring(idx + 1));
break;
}
}
// return rendered string if we have a match for every char
if (patternIdx == pattern.Length)
{
return new MatchResult
{
Success = true,
Value = sb.ToString(),
Score = CalScore(query, stringToCompare, firstMatchIndex, lastMatchIndex - firstMatchIndex)
};
}
return new MatchResult { Success = false };
}
private static int CalScore(string query, string stringToCompare, int firstIndex, int matchLen)
{
//a match found near the beginning of a string is scored more than a match found near the end
//a match is scored more if the characters in the patterns are closer to each other, while the score is lower if they are more spread out
var score = 100 * (query.Length + 1) / ((1 + firstIndex) + (matchLen + 1));
//a match with less characters assigning more weights
if (stringToCompare.Length - query.Length < 5)
score = score + 20;
else if (stringToCompare.Length - query.Length < 10)
score = score + 10;
return score;
}
public enum SearchPrecisionScore
{
Regular = 50,
Low = 20,
None = 0
}
public static bool IsSearchPrecisionScoreMet(this MatchResult matchResult)
{
var precisionScore = (SearchPrecisionScore)Enum.Parse(typeof(SearchPrecisionScore),
UserSettingSearchPrecision ?? SearchPrecisionScore.Regular.ToString());
return matchResult.Score >= (int)precisionScore;
}
public static int ScoreAfterSearchPrecisionFilter(this MatchResult matchResult)
{
return matchResult.IsSearchPrecisionScoreMet() ? matchResult.Score : 0;
}
public static int ScoreForPinyin(string source, string target)
{
if (!string.IsNullOrEmpty(source) && !string.IsNullOrEmpty(target))
@ -34,12 +137,12 @@ namespace Wox.Infrastructure
if (Alphabet.ContainsChinese(source))
{
FuzzyMatcher matcher = FuzzyMatcher.Create(target);
var combination = Alphabet.PinyinComination(source);
var pinyinScore = combination.Select(pinyin => matcher.Evaluate(string.Join("", pinyin)).Score)
var combination = Alphabet.PinyinComination(source);
var pinyinScore = combination
.Select(pinyin => FuzzySearch(target, string.Join("", pinyin), new MatchOption()).Score)
.Max();
var acronymScore = combination.Select(Alphabet.Acronym)
.Select(pinyin => matcher.Evaluate(pinyin).Score)
var acronymScore = combination.Select(Alphabet.Acronym)
.Select(pinyin => FuzzySearch(target, pinyin, new MatchOption()).Score)
.Max();
var score = Math.Max(pinyinScore, acronymScore);
return score;
@ -53,11 +156,37 @@ namespace Wox.Infrastructure
{
return 0;
}
}
}
public class MatchResult
{
public bool Success { get; set; }
public int Score { get; set; }
/// <summary>
/// hightlight string
/// </summary>
public string Value { get; set; }
}
public class MatchOption
{
public MatchOption()
{
Prefix = "";
Suffix = "";
IgnoreCase = true;
}
public static bool IsMatch(string source, string target)
{
return Score(source, target) > 0;
}
/// <summary>
/// prefix of match char, use for hightlight
/// </summary>
public string Prefix { get; set; }
/// <summary>
/// suffix of match char, use for hightlight
/// </summary>
public string Suffix { get; set; }
public bool IgnoreCase { get; set; }
}
}

View file

@ -21,6 +21,17 @@ namespace Wox.Infrastructure.UserSettings
public string ResultFontWeight { get; set; }
public string ResultFontStretch { get; set; }
private string _querySearchPrecision { get; set; } = StringMatcher.SearchPrecisionScore.Regular.ToString();
public string QuerySearchPrecision
{
get { return _querySearchPrecision; }
set
{
_querySearchPrecision = value;
StringMatcher.UserSettingSearchPrecision = value;
}
}
public bool AutoUpdates { get; set; } = false;
public double WindowLeft { get; set; }
@ -63,7 +74,6 @@ namespace Wox.Infrastructure.UserSettings
[JsonConverter(typeof(StringEnumConverter))]
public LastQueryMode LastQueryMode { get; set; } = LastQueryMode.Selected;
}
public enum LastQueryMode

View file

@ -1,4 +1,6 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using NUnit.Framework;
using Wox.Infrastructure;
@ -9,6 +11,31 @@ namespace Wox.Test
[TestFixture]
public class FuzzyMatcherTest
{
public List<string> GetSearchStrings()
=> new List<string>
{
"Chrome",
"Choose which programs you want Windows to use for activities like web browsing, editing photos, sending e-mail, and playing music.",
"Help cure hope raise on mind entity Chrome ",
"Candy Crush Saga from King",
"Uninstall or change programs on your computer",
"Add, change, and manage fonts on your computer",
"Last is chrome",
"1111"
};
public List<int> GetPrecisionScores()
{
var listToReturn = new List<int>();
Enum.GetValues(typeof(StringMatcher.SearchPrecisionScore))
.Cast<StringMatcher.SearchPrecisionScore>()
.ToList()
.ForEach(x => listToReturn.Add((int)x));
return listToReturn;
}
[Test]
public void MatchTest()
{
@ -28,7 +55,7 @@ namespace Wox.Test
results.Add(new Result
{
Title = str,
Score = FuzzyMatcher.Create("inst").Evaluate(str).Score
Score = StringMatcher.FuzzySearch("inst", str, new MatchOption()).Score
});
}
@ -39,5 +66,124 @@ namespace Wox.Test
Assert.IsTrue(results[1].Title == "Install Package");
Assert.IsTrue(results[2].Title == "file open in browser-test");
}
[TestCase("Chrome")]
public void WhenGivenNotAllCharactersFoundInSearchStringThenShouldReturnZeroScore(string searchString)
{
var compareString = "Can have rum only in my glass";
var scoreResult = StringMatcher.FuzzySearch(searchString, compareString, new MatchOption()).Score;
Assert.True(scoreResult == 0);
}
[TestCase("chr")]
[TestCase("chrom")]
[TestCase("chrome")]
[TestCase("cand")]
[TestCase("cpywa")]
[TestCase("ccs")]
public void WhenGivenStringsAndAppliedPrecisionFilteringThenShouldReturnGreaterThanPrecisionScoreResults(string searchTerm)
{
var results = new List<Result>();
foreach (var str in GetSearchStrings())
{
results.Add(new Result
{
Title = str,
Score = StringMatcher.FuzzySearch(searchTerm, str, new MatchOption()).Score
});
}
foreach (var precisionScore in GetPrecisionScores())
{
var filteredResult = results.Where(result => result.Score >= precisionScore).Select(result => result).OrderByDescending(x => x.Score).ToList();
Debug.WriteLine("");
Debug.WriteLine("###############################################");
Debug.WriteLine("SEARCHTERM: " + searchTerm + ", GreaterThanSearchPrecisionScore: " + precisionScore);
foreach (var item in filteredResult)
{
Debug.WriteLine("SCORE: " + item.Score.ToString() + ", FoundString: " + item.Title);
}
Debug.WriteLine("###############################################");
Debug.WriteLine("");
Assert.IsFalse(filteredResult.Any(x => x.Score < precisionScore));
}
}
[TestCase("chrome")]
public void WhenGivenStringsForCalScoreMethodThenShouldReturnCurrentScoring(string searchTerm)
{
var searchStrings = new List<string>
{
"Chrome",//SCORE: 107
"Last is chrome",//SCORE: 53
"Help cure hope raise on mind entity Chrome",//SCORE: 21
"Uninstall or change programs on your computer", //SCORE: 15
"Candy Crush Saga from King"//SCORE: 0
}
.OrderByDescending(x => x)
.ToList();
var results = new List<Result>();
foreach (var str in searchStrings)
{
results.Add(new Result
{
Title = str,
Score = StringMatcher.FuzzySearch(searchTerm, str, new MatchOption()).Score
});
}
var orderedResults = results.OrderByDescending(x => x.Title).ToList();
Debug.WriteLine("");
Debug.WriteLine("###############################################");
Debug.WriteLine("SEARCHTERM: " + searchTerm);
foreach (var item in orderedResults)
{
Debug.WriteLine("SCORE: " + item.Score.ToString() + ", FoundString: " + item.Title);
}
Debug.WriteLine("###############################################");
Debug.WriteLine("");
Assert.IsTrue(orderedResults[0].Score == 15 && orderedResults[0].Title == searchStrings[0]);
Assert.IsTrue(orderedResults[1].Score == 53 && orderedResults[1].Title == searchStrings[1]);
Assert.IsTrue(orderedResults[2].Score == 21 && orderedResults[2].Title == searchStrings[2]);
Assert.IsTrue(orderedResults[3].Score == 107 && orderedResults[3].Title == searchStrings[3]);
Assert.IsTrue(orderedResults[4].Score == 0 && orderedResults[4].Title == searchStrings[4]);
}
[TestCase("goo", "Google Chrome", (int)StringMatcher.SearchPrecisionScore.Regular, true)]
[TestCase("chr", "Google Chrome", (int)StringMatcher.SearchPrecisionScore.Low, true)]
[TestCase("chr", "Chrome", (int)StringMatcher.SearchPrecisionScore.Regular, true)]
[TestCase("chr", "Help cure hope raise on mind entity Chrome", (int)StringMatcher.SearchPrecisionScore.Regular, false)]
[TestCase("chr", "Help cure hope raise on mind entity Chrome", (int)StringMatcher.SearchPrecisionScore.Low, true)]
[TestCase("chr", "Candy Crush Saga from King", (int)StringMatcher.SearchPrecisionScore.Regular, false)]
[TestCase("chr", "Candy Crush Saga from King", (int)StringMatcher.SearchPrecisionScore.None, true)]
[TestCase("ccs", "Candy Crush Saga from King", (int)StringMatcher.SearchPrecisionScore.Low, true)]
[TestCase("cand", "Candy Crush Saga from King", (int)StringMatcher.SearchPrecisionScore.Regular, true)]
[TestCase("cand", "Help cure hope raise on mind entity Chrome", (int)StringMatcher.SearchPrecisionScore.Regular, false)]
public void WhenGivenDesiredPrecisionThenShouldReturnAllResultsGreaterOrEqual(string queryString, string compareString,
int expectedPrecisionScore, bool expectedPrecisionResult)
{
var expectedPrecisionString = (StringMatcher.SearchPrecisionScore)expectedPrecisionScore;
StringMatcher.UserSettingSearchPrecision = expectedPrecisionString.ToString();
var matchResult = StringMatcher.FuzzySearch(queryString, compareString, new MatchOption());
Debug.WriteLine("");
Debug.WriteLine("###############################################");
Debug.WriteLine($"SearchTerm: {queryString} PrecisionLevelSetAt: {expectedPrecisionString} ({expectedPrecisionScore})");
Debug.WriteLine($"SCORE: {matchResult.Score.ToString()}, ComparedString: {compareString}");
Debug.WriteLine("###############################################");
Debug.WriteLine("");
var matchPrecisionResult = matchResult.IsSearchPrecisionScoreMet();
Assert.IsTrue(matchPrecisionResult == expectedPrecisionResult);
}
}
}

View file

@ -45,6 +45,7 @@
<Reference Include="nunit.framework, Version=3.12.0.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL">
<HintPath>..\packages\NUnit.3.12.0\lib\net45\nunit.framework.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
</ItemGroup>
<ItemGroup>

View file

@ -54,6 +54,8 @@ namespace Wox
_settingsVM = new SettingWindowViewModel();
_settings = _settingsVM.Settings;
StringMatcher.UserSettingSearchPrecision = _settings.QuerySearchPrecision;
PluginManager.LoadPlugins(_settings.PluginSettings);
_mainVM = new MainViewModel(_settings);
var window = new MainWindow(_settings, _mainVM);

View file

@ -55,6 +55,12 @@
Checked="OnAutoStartupChecked" Unchecked="OnAutoStartupUncheck">
<TextBlock Text="{DynamicResource autoUpdates}" />
</CheckBox>
<StackPanel Margin="10" Orientation="Horizontal">
<TextBlock Text="Query Search Precision" />
<ComboBox Margin="10 0 0 0" Width="120"
ItemsSource="{Binding QuerySearchPrecisionStrings}"
SelectedItem="{Binding Settings.QuerySearchPrecision}" />
</StackPanel>
<StackPanel Margin="10" Orientation="Horizontal">
<TextBlock Text="{DynamicResource lastQueryMode}" />
<ComboBox Margin="10 0 0 0" Width="120"

View file

@ -305,8 +305,8 @@ namespace Wox.ViewModel
{
var filtered = results.Where
(
r => StringMatcher.IsMatch(r.Title, query) ||
StringMatcher.IsMatch(r.SubTitle, query)
r => StringMatcher.FuzzySearch(query, r.Title).IsSearchPrecisionScoreMet()
|| StringMatcher.FuzzySearch(query, r.SubTitle).IsSearchPrecisionScoreMet()
).ToList();
ContextMenu.AddResults(filtered, id);
}
@ -348,8 +348,8 @@ namespace Wox.ViewModel
{
var filtered = results.Where
(
r => StringMatcher.IsMatch(r.Title, query) ||
StringMatcher.IsMatch(r.SubTitle, query)
r => StringMatcher.FuzzySearch(query, r.Title).IsSearchPrecisionScoreMet() ||
StringMatcher.FuzzySearch(query, r.SubTitle).IsSearchPrecisionScoreMet()
).ToList();
History.AddResults(filtered, id);
}

View file

@ -70,6 +70,20 @@ namespace Wox.ViewModel
}
}
public List<string> QuerySearchPrecisionStrings
{
get
{
var precisionStrings = new List<string>();
var enumList = Enum.GetValues(typeof(StringMatcher.SearchPrecisionScore)).Cast<StringMatcher.SearchPrecisionScore>().ToList();
enumList.ForEach(x => precisionStrings.Add(x.ToString()));
return precisionStrings;
}
}
private Internationalization _translater => InternationalizationManager.Instance;
public List<Language> Languages => _translater.LoadAvailableLanguages();
public IEnumerable<int> MaxResultsRange => Enumerable.Range(2, 16);