Преглед на файлове

Merge pull request #5436 from MichalZr6/fix_map_sorting

Small improvements to map sorting and search mapobjects feature
Ivan Savenko преди 7 месеца
родител
ревизия
81759143f4

+ 6 - 6
client/lobby/SelectionTab.cpp

@@ -60,7 +60,7 @@ bool mapSorter::operator()(const std::shared_ptr<ElementInfo> aaa, const std::sh
 		{
 			if(boost::algorithm::starts_with(aaa->folderName, "..") || boost::algorithm::starts_with(bbb->folderName, ".."))
 				return boost::algorithm::starts_with(aaa->folderName, "..");
-			return boost::ilexicographical_compare(aaa->folderName, bbb->folderName);
+			return TextOperations::compareLocalizedStrings(aaa->folderName, bbb->folderName);
 		}
 	}
 
@@ -115,13 +115,13 @@ bool mapSorter::operator()(const std::shared_ptr<ElementInfo> aaa, const std::sh
 			return (a->victoryIconIndex < b->victoryIconIndex);
 			break;
 		case _name: //by name
-			return boost::ilexicographical_compare(a->name.toString(), b->name.toString());
+			return TextOperations::compareLocalizedStrings(aaa->name, bbb->name);
 		case _fileName: //by filename
-			return boost::ilexicographical_compare(aaa->fileURI, bbb->fileURI);
+			return TextOperations::compareLocalizedStrings(aaa->fileURI, bbb->fileURI);
 		case _changeDate: //by changedate
 			return aaa->lastWrite < bbb->lastWrite;
 		default:
-			return boost::ilexicographical_compare(a->name.toString(), b->name.toString());
+			return TextOperations::compareLocalizedStrings(aaa->name, bbb->name);
 		}
 	}
 	else //if we are sorting campaigns
@@ -131,9 +131,9 @@ bool mapSorter::operator()(const std::shared_ptr<ElementInfo> aaa, const std::sh
 		case _numOfMaps: //by number of maps in campaign
 			return aaa->campaign->scenariosCount() < bbb->campaign->scenariosCount();
 		case _name: //by name
-			return boost::ilexicographical_compare(aaa->campaign->getNameTranslated(), bbb->campaign->getNameTranslated());
+			return TextOperations::compareLocalizedStrings(aaa->campaign->getNameTranslated(), bbb->campaign->getNameTranslated());
 		default:
-			return boost::ilexicographical_compare(aaa->campaign->getNameTranslated(), bbb->campaign->getNameTranslated());
+			return TextOperations::compareLocalizedStrings(aaa->campaign->getNameTranslated(), bbb->campaign->getNameTranslated());
 		}
 	}
 }

+ 9 - 3
client/windows/CSpellWindow.cpp

@@ -92,7 +92,7 @@ public:
 				return false;
 		}
 
-		return A->getNameTranslated() < B->getNameTranslated();
+		return TextOperations::compareLocalizedStrings(A->getNameTranslated(), B->getNameTranslated());
 	}
 };
 
@@ -241,12 +241,18 @@ void CSpellWindow::processSpells()
 	mySpells.reserve(LIBRARY->spellh->objects.size());
 	for(auto const & spell : LIBRARY->spellh->objects)
 	{
-		bool searchTextFound = !searchBox || TextOperations::textSearchSimilar(searchBox->getText(), spell->getNameTranslated());
+		bool searchTextFound = !searchBox || TextOperations::textSearchSimilarityScore(searchBox->getText(), spell->getNameTranslated());
 
 		if(onSpellSelect)
 		{
-			if(spell->isCombat() == openOnBattleSpells && !spell->isSpecial() && !spell->isCreatureAbility() && searchTextFound && (showAllSpells->isSelected() || myHero->canCastThisSpell(spell.get())))
+			if(spell->isCombat() == openOnBattleSpells
+				&& !spell->isSpecial()
+				&& !spell->isCreatureAbility()
+				&& searchTextFound
+				&& (showAllSpells->isSelected() || myHero->canCastThisSpell(spell.get())))
+			{
 				mySpells.push_back(spell.get());
+			}
 			continue;
 		}
 

+ 67 - 15
client/windows/GUIClasses.cpp

@@ -39,6 +39,7 @@
 #include "../render/Canvas.h"
 #include "../render/IRenderHandler.h"
 #include "../render/IImage.h"
+#include "../render/IFont.h"
 
 #include "../../CCallback.h"
 
@@ -1531,7 +1532,11 @@ CObjectListWindow::CObjectListWindow(const std::vector<int> & _items, std::share
 	items.reserve(_items.size());
 
 	for(int id : _items)
-		items.push_back(std::make_pair(id, GAME->interface()->cb->getObjInstance(ObjectInstanceID(id))->getObjectName()));
+	{
+		std::string objectName = GAME->interface()->cb->getObjInstance(ObjectInstanceID(id))->getObjectName();
+		trimTextIfTooWide(objectName, id);
+		items.emplace_back(id, objectName);
+	}
 	itemsVisible = items;
 
 	init(titleWidget_, _title, _descr, searchBoxEnabled);
@@ -1550,8 +1555,12 @@ CObjectListWindow::CObjectListWindow(const std::vector<std::string> & _items, st
 
 	items.reserve(_items.size());
 
-	for(size_t i=0; i<_items.size(); i++)
-		items.push_back(std::make_pair(int(i), _items[i]));
+	for(size_t i = 0; i < _items.size(); i++)
+	{
+		std::string objectName = _items[i];
+		trimTextIfTooWide(objectName, static_cast<int>(i));
+		items.emplace_back(static_cast<int>(i), objectName);
+	}
 	itemsVisible = items;
 
 	init(titleWidget_, _title, _descr, searchBoxEnabled);
@@ -1570,7 +1579,7 @@ void CObjectListWindow::init(std::shared_ptr<CIntObject> titleWidget_, std::stri
 	{
 		addChild(titleWidget.get());
 		titleWidget->pos.x = pos.w/2 + pos.x - titleWidget->pos.w/2;
-		titleWidget->pos.y =75 + pos.y - titleWidget->pos.h/2;
+		titleWidget->pos.y = 75 + pos.y - titleWidget->pos.h/2;
 	}
 	list = std::make_shared<CListBox>(std::bind(&CObjectListWindow::genItem, this, _1),
 		Point(14, 151), Point(0, 25), 9, itemsVisible.size(), 0, 1, Rect(262, -32, 256, 256) );
@@ -1590,21 +1599,64 @@ void CObjectListWindow::init(std::shared_ptr<CIntObject> titleWidget_, std::stri
 	searchBoxDescription = std::make_shared<CLabel>(r.center().x, r.center().y, FONT_SMALL, ETextAlignment::CENTER, grayedColor, LIBRARY->generaltexth->translate("vcmi.spellBook.search"));
 
 	searchBox = std::make_shared<CTextInput>(r, FONT_SMALL, ETextAlignment::CENTER, true);
-	searchBox->setCallback([this](const std::string & text){
-		searchBoxDescription->setEnabled(text.empty());
+	searchBox->setCallback(std::bind(&CObjectListWindow::itemsSearchCallback, this, std::placeholders::_1));
+}
+
+void CObjectListWindow::trimTextIfTooWide(std::string & text, int id) const
+{
+	int maxWidth = pos.w - 60;	// 60 px for scrollbar and borders
+	std::string idStr = '(' + std::to_string(id) + ')';
+	const auto & font = ENGINE->renderHandler().loadFont(FONT_SMALL);
+	std::string suffix = " ... " + idStr;
 
-		itemsVisible.clear();
-		for(auto & item : items)
-			if(TextOperations::textSearchSimilar(text, item.second))
-				itemsVisible.push_back(item);
+	if(font->getStringWidth(text) >= maxWidth)
+	{
+		logGlobal->warn("Mapobject name '%s' is too long and probably needs to be fixed! Trimming...", 
+			text.substr(0, text.size() - idStr.size() + 1));
+
+		// Trim text until it fits
+		while(!text.empty())
+		{
+			std::string trimmedText = text + suffix;
 
-		selected = 0;
-		list->resize(itemsVisible.size());
-		list->scrollTo(0);
-		ok->block(!itemsVisible.size());
+			if(font->getStringWidth(trimmedText) < maxWidth)
+				break;
 
-		redraw();
+			TextOperations::trimRightUnicode(text);
+		}
+
+		text += suffix;
+	}
+}
+
+void CObjectListWindow::itemsSearchCallback(const std::string & text)
+{
+	searchBoxDescription->setEnabled(text.empty());
+
+	itemsVisible.clear();
+	std::vector<std::pair<int, decltype(items)::value_type>> rankedItems; // Store (score, item)
+
+	for(const auto & item : items)
+	{
+		if(auto score = TextOperations::textSearchSimilarityScore(text, item.second)) // Keep only relevant items
+			rankedItems.emplace_back(score.value(), item);
+	}
+
+	// Sort: Lower score is better match
+	std::sort(rankedItems.begin(), rankedItems.end(), [](const auto & a, const auto & b)
+	{
+		return a.first < b.first;
 	});
+
+	for(const auto & rankedItem : rankedItems)
+		itemsVisible.push_back(rankedItem.second);
+
+	selected = 0;
+	list->resize(itemsVisible.size());
+	list->scrollTo(0);
+	ok->block(!itemsVisible.size());
+
+	redraw();
 }
 
 std::shared_ptr<CIntObject> CObjectListWindow::genItem(size_t index)

+ 2 - 0
client/windows/GUIClasses.h

@@ -197,6 +197,8 @@ class CObjectListWindow : public CWindowObject
 	std::vector< std::pair<int, std::string> > itemsVisible; //visible items present in list
 
 	void init(std::shared_ptr<CIntObject> titleWidget_, std::string _title, std::string _descr, bool searchBoxEnabled);
+	void trimTextIfTooWide(std::string & text, int id) const; // trim item's text to fit within window's width
+	void itemsSearchCallback(const std::string & text);
 	void exitPressed();
 public:
 	size_t selected;//index of currently selected item

+ 0 - 3
lib/VCMIDirs.cpp

@@ -171,9 +171,6 @@ class VCMIDirsWIN32 final : public IVCMIDirs
 
 void VCMIDirsWIN32::init()
 {
-	std::locale::global(boost::locale::generator().generate("en_US.UTF-8"));
-	boost::filesystem::path::imbue(std::locale());
-
 	// Call base (init dirs)
 	IVCMIDirs::init();
 

+ 4 - 0
lib/mapping/CMapInfo.cpp

@@ -48,6 +48,8 @@ void CMapInfo::mapInit(const std::string & fname)
 	originalFileURI = resource.getOriginalName();
 	fullFileURI = boost::filesystem::canonical(*CResourceHandler::get()->getResourceName(resource)).string();
 	mapHeader = mapService.loadMapHeader(resource);
+	lastWrite = boost::filesystem::last_write_time(*CResourceHandler::get()->getResourceName(resource));
+	date = TextOperations::getFormattedDateTimeLocal(lastWrite);
 	countPlayers();
 }
 
@@ -76,6 +78,8 @@ void CMapInfo::campaignInit()
 	originalFileURI = resource.getOriginalName();
 	fullFileURI = boost::filesystem::canonical(*CResourceHandler::get()->getResourceName(resource)).string();
 	campaign = CampaignHandler::getHeader(fileURI);
+	lastWrite = boost::filesystem::last_write_time(*CResourceHandler::get()->getResourceName(resource));
+	date = TextOperations::getFormattedDateTimeLocal(lastWrite);
 }
 
 void CMapInfo::countPlayers()

+ 25 - 22
lib/texts/Languages.h

@@ -66,6 +66,9 @@ struct Options
 	/// encoding that is used by H3 for this language
 	std::string encoding;
 
+	/// proper locale name, e.g. "en_US.UTF-8"
+	std::string localeName;
+
 	/// primary IETF language tag
 	std::string tagIETF;
 
@@ -86,28 +89,28 @@ inline const auto & getLanguageList()
 {
 	static const std::array<Options, 22> languages
 	{ {
-		{ "bulgarian",   "Bulgarian",   "Български",  "CP1251", "bg", "bul", "%d.%m.%Y %H:%M",    EPluralForms::EN_2, true  },
-		{ "czech",       "Czech",       "Čeština",    "CP1250", "cs", "cze", "%d.%m.%Y %H:%M",    EPluralForms::CZ_3, true  },
-		{ "chinese",     "Chinese",     "简体中文",    "GBK",    "zh", "chi", "%Y-%m-%d %H:%M",    EPluralForms::VI_1, true  }, // Note: actually Simplified Chinese
-		{ "english",     "English",     "English",    "CP1252", "en", "eng", "%Y-%m-%d %H:%M",    EPluralForms::EN_2, true  }, // English uses international date/time format here
-		{ "finnish",     "Finnish",     "Suomi",      "CP1252", "fi", "fin", "%d.%m.%Y %H:%M",    EPluralForms::EN_2, true  },
-		{ "french",      "French",      "Français",   "CP1252", "fr", "fre", "%d/%m/%Y %H:%M",    EPluralForms::FR_2, true  },
-		{ "german",      "German",      "Deutsch",    "CP1252", "de", "ger", "%d.%m.%Y %H:%M",    EPluralForms::EN_2, true  },
-		{ "greek",       "Greek",       "ελληνικά",   "CP1253", "el", "ell", "%d/%m/%Y %H:%M",    EPluralForms::EN_2, false },
-		{ "hungarian",   "Hungarian",   "Magyar",     "CP1250", "hu", "hun", "%Y. %m. %d. %H:%M", EPluralForms::EN_2, true  },
-		{ "italian",     "Italian",     "Italiano",   "CP1250", "it", "ita", "%d/%m/%Y %H:%M",    EPluralForms::EN_2, true  },
-		{ "japanese",    "Japanese",    "日本語",      "JIS",    "ja", "jpn", "%Y年%m月%d日 %H:%M", EPluralForms::NONE, false },
-		{ "korean",      "Korean",      "한국어",      "CP949",  "ko", "kor", "%Y-%m-%d %H:%M",    EPluralForms::VI_1, true  },
-		{ "polish",      "Polish",      "Polski",     "CP1250", "pl", "pol", "%d.%m.%Y %H:%M",    EPluralForms::PL_3, true  },
-		{ "portuguese",  "Portuguese",  "Português",  "CP1252", "pt", "por", "%d/%m/%Y %H:%M",    EPluralForms::EN_2, true  }, // Note: actually Brazilian Portuguese
-		{ "romanian",    "Romanian",    "Română",     "CP28606","ro", "rum", "%Y-%m-%d %H:%M",    EPluralForms::RO_3, false },
-		{ "russian",     "Russian",     "Русский",    "CP1251", "ru", "rus", "%d.%m.%Y %H:%M",    EPluralForms::UK_3, true  },
-		{ "spanish",     "Spanish",     "Español",    "CP1252", "es", "spa", "%d/%m/%Y %H:%M",    EPluralForms::EN_2, true  },
-		{ "swedish",     "Swedish",     "Svenska",    "CP1252", "sv", "swe", "%Y-%m-%d %H:%M",    EPluralForms::EN_2, true  },
-		{ "norwegian",   "Norwegian",   "Norsk",      "CP1252", "no", "nor", "%d/%m/%Y %H:%M",    EPluralForms::EN_2, false },
-		{ "turkish",     "Turkish",     "Türkçe",     "CP1254", "tr", "tur", "%d.%m.%Y %H:%M",    EPluralForms::EN_2, true  },
-		{ "ukrainian",   "Ukrainian",   "Українська", "CP1251", "uk", "ukr", "%d.%m.%Y %H:%M",    EPluralForms::UK_3, true  },
-		{ "vietnamese",  "Vietnamese",  "Tiếng Việt", "UTF-8",  "vi", "vie", "%d/%m/%Y %H:%M",    EPluralForms::VI_1, true  }, // Fan translation uses special encoding
+		{ "bulgarian",   "Bulgarian",   "Български",  "CP1251", "bg_BG.UTF-8", "bg", "bul", "%d.%m.%Y %H:%M",    EPluralForms::EN_2, true  },
+		{ "czech",       "Czech",       "Čeština",		"CP1250", "cs_CZ.UTF-8", "cs", "cze", "%d.%m.%Y %H:%M",		EPluralForms::CZ_3, true },
+		{ "chinese",     "Chinese",     "简体中文",		"GBK",	  "zh_CN.UTF-8", "zh", "chi", "%Y-%m-%d %H:%M",		EPluralForms::VI_1, true }, // Note: actually Simplified Chinese
+		{ "english",     "English",     "English",		"CP1252", "en_US.UTF-8", "en", "eng", "%Y-%m-%d %H:%M",		EPluralForms::EN_2, true }, // English uses international date/time format here
+		{ "finnish",     "Finnish",     "Suomi",		"CP1252", "fi_FI.UTF-8", "fi", "fin", "%d.%m.%Y %H:%M",		EPluralForms::EN_2, true },
+		{ "french",      "French",      "Français",		"CP1252", "fr_FR.UTF-8", "fr", "fre", "%d/%m/%Y %H:%M",		EPluralForms::FR_2, true },
+		{ "german",      "German",      "Deutsch",		"CP1252", "de_DE.UTF-8", "de", "ger", "%d.%m.%Y %H:%M",		EPluralForms::EN_2, true },
+		{ "greek",       "Greek",       "ελληνικά",		"CP1253", "el_GR.UTF-8", "el", "ell", "%d/%m/%Y %H:%M",		EPluralForms::EN_2, false },
+		{ "hungarian",   "Hungarian",   "Magyar",		"CP1250", "hu_HU.UTF-8", "hu", "hun", "%Y. %m. %d. %H:%M",  EPluralForms::EN_2, true },
+		{ "italian",     "Italian",     "Italiano",		"CP1250", "it_IT.UTF-8", "it", "ita", "%d/%m/%Y %H:%M",		EPluralForms::EN_2, true },
+		{ "japanese",    "Japanese",    "日本語",		"JIS",    "ja_JP.UTF-8", "ja", "jpn", "%Y年%m月%d日 %H:%M",	EPluralForms::NONE, false },
+		{ "korean",      "Korean",      "한국어",		"CP949",  "ko_KR.UTF-8", "ko", "kor", "%Y-%m-%d %H:%M",		EPluralForms::VI_1, true },
+		{ "polish",      "Polish",      "Polski",		"CP1250", "pl_PL.UTF-8", "pl", "pol", "%d.%m.%Y %H:%M",		EPluralForms::PL_3, true },
+		{ "portuguese",  "Portuguese",  "Português",	"CP1252", "pt_BR.UTF-8", "pt", "por", "%d/%m/%Y %H:%M",		EPluralForms::EN_2, true }, // Note: actually Brazilian Portuguese
+		{ "romanian",    "Romanian",    "Română",     "CP28606", "ro_RO.UTF-8", "ro", "rum", "%Y-%m-%d %H:%M",    EPluralForms::RO_3, false },
+		{ "russian",     "Russian",     "Русский",		"CP1251", "ru_RU.UTF-8", "ru", "rus", "%d.%m.%Y %H:%M",		EPluralForms::UK_3, true },
+		{ "spanish",     "Spanish",     "Español",		"CP1252", "es_ES.UTF-8", "es", "spa", "%d/%m/%Y %H:%M",		EPluralForms::EN_2, true },
+		{ "swedish",     "Swedish",     "Svenska",		"CP1252", "sv_SE.UTF-8", "sv", "swe", "%Y-%m-%d %H:%M",		EPluralForms::EN_2, true },
+		{ "norwegian",   "Norwegian",   "Norsk Bokmål", "UTF-8",  "nb_NO.UTF-8", "nb", "nor", "%d/%m/%Y %H:%M",		EPluralForms::EN_2, false },
+		{ "turkish",     "Turkish",     "Türkçe",		"CP1254", "tr_TR.UTF-8", "tr", "tur", "%d.%m.%Y %H:%M",		EPluralForms::EN_2, true },
+		{ "ukrainian",   "Ukrainian",   "Українська",	"CP1251", "uk_UA.UTF-8", "uk", "ukr", "%d.%m.%Y %H:%M",		EPluralForms::UK_3, true },
+		{ "vietnamese",  "Vietnamese",  "Tiếng Việt",	"UTF-8",  "vi_VN.UTF-8", "vi", "vie", "%d/%m/%Y %H:%M",		EPluralForms::VI_1, true }, // Fan translation uses special encoding
 	} };
 	static_assert(languages.size() == static_cast<size_t>(ELanguages::COUNT), "Languages array is missing a value!");
 

+ 31 - 18
lib/texts/TextOperations.cpp

@@ -10,7 +10,8 @@
 #include "StdInc.h"
 #include "TextOperations.h"
 
-#include "texts/CGeneralTextHandler.h"
+#include "../GameLibrary.h"
+#include "../texts/CGeneralTextHandler.h"
 #include "Languages.h"
 #include "CConfigHandler.h"
 
@@ -252,7 +253,7 @@ std::string TextOperations::getCurrentFormattedDateTimeLocal(std::chrono::second
 	return TextOperations::getFormattedDateTimeLocal(std::chrono::system_clock::to_time_t(timepoint));
 }
 
-int TextOperations::getLevenshteinDistance(const std::string & s, const std::string & t)
+int TextOperations::getLevenshteinDistance(std::string_view s, std::string_view t)
 {
 	int n = t.size();
 	int m = s.size();
@@ -300,30 +301,42 @@ int TextOperations::getLevenshteinDistance(const std::string & s, const std::str
 	return v0[n];
 }
 
-bool TextOperations::textSearchSimilar(const std::string & s, const std::string & t)
+DLL_LINKAGE std::string TextOperations::getLocaleName()
 {
-	boost::locale::generator gen;
-	std::locale loc = gen("en_US.UTF-8"); // support for UTF8 lowercase
-	
+	return Languages::getLanguageOptions(LIBRARY->generaltexth->getPreferredLanguage()).localeName;
+}
+
+std::optional<int> TextOperations::textSearchSimilarityScore(const std::string & s, const std::string & t)
+{
+	static const std::locale loc = boost::locale::generator().generate(getLocaleName());
+
 	auto haystack = boost::locale::to_lower(t, loc);
 	auto needle = boost::locale::to_lower(s, loc);
 
-	if(boost::algorithm::contains(haystack, needle))
-		return true;
+	// 0 - Best possible match: text starts with the search string
+	if(haystack.rfind(needle, 0) == 0)
+		return 0;
 
-	if(needle.size() > haystack.size())
-		return false;
+	// 1 - Strong match: text contains the search string
+	if(haystack.find(needle) != std::string::npos)
+		return 1;
+
+	// Dynamic threshold: Reject if too many typos based on word length
+	int maxAllowedDistance = std::max(2, static_cast<int>(needle.size() / 2));
 
-	for(int i = 0; i < haystack.size() - needle.size() + 1; i++)
+	// Compute Levenshtein distance for fuzzy similarity
+	int minDist = std::numeric_limits<int>::max();
+	for(size_t i = 0; i <= haystack.size() - needle.size(); i++)
 	{
-		auto dist = getLevenshteinDistance(haystack.substr(i, needle.size()), needle);
-		if(needle.size() > 2 && dist <= 1)
-			return true;
-		else if(needle.size() > 4 && dist <= 2)
-			return true;
+		int dist = getLevenshteinDistance(haystack.substr(i, needle.size()), needle);
+		minDist = std::min(minDist, dist);
 	}
-	
-	return false;
+
+	// Apply scaling: Short words tolerate smaller distances
+	if(needle.size() > 2 && minDist <= 2)
+		minDist += 1;
+
+	return (minDist > maxAllowedDistance) ? std::nullopt : std::optional<int>{ minDist };
 }
 
 VCMI_LIB_NAMESPACE_END

+ 18 - 2
lib/texts/TextOperations.h

@@ -74,10 +74,26 @@ namespace TextOperations
 	/// Algorithm for detection of typos in words
 	/// Determines how 'different' two strings are - how many changes must be done to turn one string into another one
 	/// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
-	DLL_LINKAGE int getLevenshteinDistance(const std::string & s, const std::string & t);
+	DLL_LINKAGE int getLevenshteinDistance(std::string_view s, std::string_view t);
+
+	/// Retrieves the locale name based on the selected (in config) game language.
+	DLL_LINKAGE std::string getLocaleName();
+
+	/// Compares two strings using locale-aware collation based on the selected game language.
+	DLL_LINKAGE inline bool compareLocalizedStrings(std::string_view str1, std::string_view str2)
+	{
+		static const std::locale loc(getLocaleName());
+		static const std::collate<char> & col = std::use_facet<std::collate<char>>(loc);
+
+		return col.compare(str1.data(), str1.data() + str1.size(),
+			str2.data(), str2.data() + str2.size()) < 0;
+	}
 
 	/// Check if texts have similarity when typing into search boxes
-	DLL_LINKAGE bool textSearchSimilar(const std::string & s, const std::string & t);
+	/// 0 -> Exact match or starts with typed-in text, 1 -> Close match or substring match, 
+	/// other values = Levenshtein distance, returns std::nullopt for unrelated word (bad match).
+	DLL_LINKAGE std::optional<int> textSearchSimilarityScore(const std::string & s, const std::string & t);
+
 };
 
 template<typename Arithmetic>