From 65e703f40281369fcb1c5530488456d02009e9ec Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Mon, 27 Apr 2026 10:59:46 +0300 Subject: [PATCH 01/16] Implemented searching algorithm --- mods/core/modules/modmenu_search.py | 110 ++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 mods/core/modules/modmenu_search.py diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py new file mode 100644 index 0000000..3023948 --- /dev/null +++ b/mods/core/modules/modmenu_search.py @@ -0,0 +1,110 @@ +import collections + + +# Copied from nltk (https://www.nltk.org/_modules/nltk/metrics/distance.html#jaro_similarity) +def jaro_similarity(s1, s2): + """ + Computes the Jaro similarity between 2 sequences from: + + Matthew A. Jaro (1989). Advances in record linkage methodology + as applied to the 1985 census of Tampa Florida. Journal of the + American Statistical Association. 84 (406): 414-20. + + The Jaro distance between is the min no. of single-character transpositions + required to change one word into another. The Jaro similarity formula from + https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance : + + ``jaro_sim = 0 if m = 0 else 1/3 * (m/|s_1| + m/s_2 + (m-t)/m)`` + + where + - `|s_i|` is the length of string `s_i` + - `m` is the no. of matching characters + - `t` is the half no. of possible transpositions. + """ + # First, store the length of the strings + # because they will be re-used several times. + len_s1, len_s2 = len(s1), len(s2) + + # The upper bound of the distance for being a matched character. + match_bound = int(max(len_s1, len_s2) / 2) - 1 + + # Initialize the counts for matches and transpositions. + matches = 0 # no.of matched characters in s1 and s2 + transpositions = 0 # no. of transpositions between s1 and s2 + flagged_1 = [] # positions in s1 which are matches to some character in s2 + flagged_2 = [] # positions in s2 which are matches to some character in s1 + + # Iterate through sequences, check for matches and compute transpositions. + for i in range(len_s1): # Iterate through each character. + upperbound = min(i + match_bound, len_s2 - 1) + lowerbound = max(0, i - match_bound) + for j in range(lowerbound, upperbound + 1): + if s1[i] == s2[j] and j not in flagged_2: + matches += 1 + flagged_1.append(i) + flagged_2.append(j) + break + flagged_2.sort() + for i, j in zip(flagged_1, flagged_2): + if s1[i] != s2[j]: + transpositions += 1 + + if matches == 0: + return 0 + else: + matches = float(matches) + return ( + 1 / 3.0 + * ( + matches / len_s1 + + matches / len_s2 + + (matches - int(transpositions / 2)) / matches + ) + ) + +def jaro_counter_similarity(query_counter, target_counter): + """Finds approximate similarity between word counter query_counter and word counter target_counter. + each word in query_counter is compared against all of target_counter's words to find the best match. + the similarity is then the weighted average of each of those best similarity numbers, weighted by word count in query_counter""" + + best_similarity = {} + for query_word in query_counter.iterkeys(): + curr_best = 0 + for target_word in target_counter.iterkeys(): + curr_best = max(curr_best, jaro_similarity(query_word, target_word)) + best_similarity[query_word] = curr_best + + return sum(best_similarity[query_word] * count for query_word, count in query_counter.iteritems()) / sum(query_counter.itervalues()) + +def jaro_split_compare(query, modlist): + """Compare the modlist to the query using jaro similarity on each word. + :returns dict from modname to similarity tuple, which contains the similarity of query to modname, then the similarity of query to mod description. + """ + comps = {} + query_words = collections.Counter(query.lower().split()) + + for _, name, _, desc, _ in modlist: + name_words = collections.Counter(name.lower().split()) + name_similarity = jaro_counter_similarity(query_words, name_words) + + desc_words = collections.Counter(desc.lower().split()) + desc_similarity = jaro_counter_similarity(query_words, desc_words) + + comps[name] = (name_similarity, desc_similarity) + + return comps + + +def sort_best(query, modlist, return_score = False): + """Sort mods by best match to query""" + similarities = jaro_split_compare(query, modlist) + + mod_order = [entry for entry in sorted(similarities.items(), key=lambda e: max(e[1]), reverse=True)] + + mods_by_name = {mod[1]: mod for mod in modlist} + + if return_score: + return [(mods_by_name[name], score) for name, score in mod_order] + else: + return [mods_by_name[name] for name, _ in mod_order] + From 70ff6176d5a2e91457441e75125b725480c93f6a Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Mon, 27 Apr 2026 12:37:06 +0300 Subject: [PATCH 02/16] Implemented mod searchbar --- mods/core/download_mods.rpy | 46 +++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 8cc6714..0f96ce1 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -153,6 +153,32 @@ init python: modconfig.steam_modlist_preloader.load() + from modmenu_search import sort_best + import time + + def search_modlist(query): + # As renpy input doesn't allow for additional variables, I've had to resort to this cursed thing + curr_screen_scope = renpy.current_screen().scope + + modlist = curr_screen_scope["contents"] + page = curr_screen_scope["current_page"] + page_size = curr_screen_scope["PAGE_SIZE"] + use_steam = curr_screen_scope["use_steam"] + + s_time = time.time() + if query.strip(): + sorted_ml = sort_best(query, modlist) + else: + sorted_ml = curr_screen_scope["contents"] + print "Search took: {:.5}".format(time.time() - s_time) + + curr_screen_scope["ordered_contents"] = sorted_ml + _refresh_modlist_page(page, page_size, sorted_ml, use_steam) + renpy.restart_interaction() + + return + + init -1 python: import sys import math @@ -393,6 +419,10 @@ screen modmenu_paged(contents, use_steam): $ MIN_PAGE = 1 # Do note, modpage numbers are 1-indexed $ MAX_PAGE = int(math.ceil(len(contents) / float(PAGE_SIZE))) + # ordered_contents used for searchbar sorting. as it needs to be set mainly from that function, we use this if node so that it only gets set here if missing + if not "ordered_contents" in renpy.current_screen().scope: + $ ordered_contents = contents + frame id "modmenu_paged" at alpha_dissolve: add "image/ui/ingame_menu_bg3.png" @@ -440,7 +470,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 # Tried to bind this to shift+scroll, but it didn't work... action [SetScreenVariable("current_page", max(current_page-5, MIN_PAGE)), - Function(_refresh_modlist_page, max(current_page-5, MIN_PAGE), PAGE_SIZE, contents, use_steam=use_steam) + Function(_refresh_modlist_page, max(current_page-5, MIN_PAGE), PAGE_SIZE, ordered_contents, use_steam=use_steam) ] sensitive (current_page > 1) @@ -449,7 +479,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 keysym "mousedown_4" action [SetScreenVariable("current_page", current_page-1), - Function(_refresh_modlist_page, current_page-1, PAGE_SIZE, contents, use_steam=use_steam) + Function(_refresh_modlist_page, current_page-1, PAGE_SIZE, ordered_contents, use_steam=use_steam) ] sensitive (current_page > 1) @@ -464,7 +494,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 keysym "mousedown_5" action [SetScreenVariable("current_page", current_page+1), - Function(_refresh_modlist_page, current_page+1, PAGE_SIZE, contents, use_steam=use_steam) + Function(_refresh_modlist_page, current_page+1, PAGE_SIZE, ordered_contents, use_steam=use_steam) ] sensitive (current_page < MAX_PAGE) @@ -473,10 +503,18 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 # Also tried to bind this to shift+scroll, but it didn't work... action [SetScreenVariable("current_page", min(current_page+5, MAX_PAGE)), - Function(_refresh_modlist_page, min(current_page+5, MAX_PAGE), PAGE_SIZE, contents, use_steam=use_steam) + Function(_refresh_modlist_page, min(current_page+5, MAX_PAGE), PAGE_SIZE, ordered_contents, use_steam=use_steam) ] sensitive (current_page < MAX_PAGE) + input default "" changed search_modlist: + size 30 +# xpos 0.15 + ypos 0.05 + xcenter 0.15 + yanchor 0.5 + + on "show" action [Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam), Function(_preload_mod_images, contents, None)] From 7db102eca716106e3a237689a5480ed6c308f5b7 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Mon, 27 Apr 2026 13:03:01 +0300 Subject: [PATCH 03/16] Cached jaro similarity results to significantly improve performance on long queries --- mods/core/modules/modmenu_search.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 3023948..d9f28c0 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -1,7 +1,17 @@ import collections +def cache(function): + def inner(*args): + if not hasattr(function, "results"): + function.results = {args: function(*args)} + elif args not in function.results: + function.results[args] = function(*args) + return function.results[args] + return inner + # Copied from nltk (https://www.nltk.org/_modules/nltk/metrics/distance.html#jaro_similarity) +@cache def jaro_similarity(s1, s2): """ Computes the Jaro similarity between 2 sequences from: From e3d99e6c16ee67f4fd70fd426113127efc81c6e6 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 28 Apr 2026 08:46:33 +0300 Subject: [PATCH 04/16] Added word-to-sentence caching to make runtime depend only on longest word in query --- mods/core/modules/modmenu_search.py | 61 +++++++++++++++++++---------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index d9f28c0..4e66595 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -36,7 +36,7 @@ def jaro_similarity(s1, s2): len_s1, len_s2 = len(s1), len(s2) # The upper bound of the distance for being a matched character. - match_bound = int(max(len_s1, len_s2) / 2) - 1 + match_bound = max(int(max(len_s1, len_s2) / 2) - 1, 0) # My one change from the original algorithm: allows two length=1 words to match if they are the same word. # Initialize the counts for matches and transpositions. matches = 0 # no.of matched characters in s1 and s2 @@ -72,20 +72,47 @@ def jaro_similarity(s1, s2): ) ) -def jaro_counter_similarity(query_counter, target_counter): - """Finds approximate similarity between word counter query_counter and word counter target_counter. - each word in query_counter is compared against all of target_counter's words to find the best match. - the similarity is then the weighted average of each of those best similarity numbers, weighted by word count in query_counter""" + + +_jaro_best_match_cache = {} + + +def jaro_counter_similarity(query_counter, target1, target2): + """Finds approximate similarity between word counter query_counter and target word strings target1 and target2. + each word in query_counter is compared against all of target1's and target2's words to find the best match. + the similarity is then the weighted average of each of those best similarity numbers, weighted by word count in query_counter. + these results are cached by target1 for each word of query_counter, and as such, for each value of target1 there should only be a single value of target2.""" - best_similarity = {} - for query_word in query_counter.iterkeys(): - curr_best = 0 - for target_word in target_counter.iterkeys(): - curr_best = max(curr_best, jaro_similarity(query_word, target_word)) - best_similarity[query_word] = curr_best - return sum(best_similarity[query_word] * count for query_word, count in query_counter.iteritems()) / sum(query_counter.itervalues()) + best_similarity_1 = {} + best_similarity_2 = {} + for query_word in query_counter.iterkeys(): + if (query_word, target1) not in _jaro_best_match_cache: + t1_counter = collections.Counter(target1.lower().split()) + curr_best_1 = 0 + for target_word in t1_counter.iterkeys(): + curr_best_1 = max(curr_best_1, jaro_similarity(query_word, target_word)) + best_similarity_1[query_word] = curr_best_1 + + t2_counter = collections.Counter(target2.lower().split()) + curr_best_2 = 0 + for target_word in t2_counter.iterkeys(): + curr_best_2 = max(curr_best_2, jaro_similarity(query_word, target_word)) + best_similarity_2[query_word] = curr_best_2 + + + _jaro_best_match_cache[(query_word, target1)] = (curr_best_1, curr_best_2) + else: + sim1, sim2 = _jaro_best_match_cache[(query_word, target1)] + best_similarity_1[query_word] = sim1 + best_similarity_2[query_word] = sim2 + n_values = sum(query_counter.itervalues()) + return (sum(best_similarity_1[query_word] * count for query_word, count in query_counter.iteritems()) / n_values, + sum(best_similarity_2[query_word] * count for query_word, count in query_counter.iteritems()) / n_values) + + + def jaro_split_compare(query, modlist): """Compare the modlist to the query using jaro similarity on each word. :returns dict from modname to similarity tuple, which contains the similarity of query to modname, then the similarity of query to mod description. @@ -94,18 +121,12 @@ def jaro_split_compare(query, modlist): query_words = collections.Counter(query.lower().split()) for _, name, _, desc, _ in modlist: - name_words = collections.Counter(name.lower().split()) - name_similarity = jaro_counter_similarity(query_words, name_words) - - desc_words = collections.Counter(desc.lower().split()) - desc_similarity = jaro_counter_similarity(query_words, desc_words) - - comps[name] = (name_similarity, desc_similarity) + comps[name] = jaro_counter_similarity(query_words, name, desc) return comps -def sort_best(query, modlist, return_score = False): +def sort_best(query, modlist, return_score=False): """Sort mods by best match to query""" similarities = jaro_split_compare(query, modlist) From 6fa789912d6d1ab520ee95ff7802ed3c73b3b29a Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 28 Apr 2026 09:33:43 +0300 Subject: [PATCH 05/16] Improved look of search bar --- mods/core/download_mods.rpy | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 0f96ce1..05dea49 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -508,10 +508,11 @@ screen modmenu_paged(contents, use_steam): sensitive (current_page < MAX_PAGE) input default "" changed search_modlist: - size 30 -# xpos 0.15 + size 34 + color "#FFF000" + xpos 0.034 ypos 0.05 - xcenter 0.15 + xanchor 0.0 yanchor 0.5 From 6e678c8377d54c5697b064b906832e52acbc9d42 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 28 Apr 2026 09:35:01 +0300 Subject: [PATCH 06/16] Added modname bias to search function to produce better results --- mods/core/modules/modmenu_search.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 4e66595..31922d1 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -130,7 +130,10 @@ def sort_best(query, modlist, return_score=False): """Sort mods by best match to query""" similarities = jaro_split_compare(query, modlist) - mod_order = [entry for entry in sorted(similarities.items(), key=lambda e: max(e[1]), reverse=True)] + # Sort by best match, with bias to strong modname matches + # This bias is useful as the description normally takes the stronger value, unless the mod name is searched specifically. + # Max gave me better results than sum, so I used it. + mod_order = [entry for entry in sorted(similarities.items(), key=lambda e: (max(e[1]) + int(e[1][0] > 0.9) * e[1][0]), reverse=True)] mods_by_name = {mod[1]: mod for mod in modlist} From 7c48d817a7f2c7f8f408b785488ae6de9ce13c69 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 12 May 2026 10:08:50 +0300 Subject: [PATCH 07/16] Cleared image cache on enter and exit of the modmenu to avoid out of memory issues --- mods/core/download_mods.rpy | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 05dea49..eab8c94 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -517,9 +517,12 @@ screen modmenu_paged(contents, use_steam): on "show" action [Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam), - Function(_preload_mod_images, contents, None)] + Function(_preload_mod_images, contents, None), + Function(im.cache.clear) # I tended to get 'out of memory' errors on this menu, so we use this precaution + ] - on "hide" action [Function(mod_image_preloader.clear)] + on "hide" action [Function(mod_image_preloader.clear), # Cleanup after ourselves + Function(im.cache.clear)] From 5044022cb42c3ac4978e4688cc1ddc8d20a5b1e6 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Sun, 24 May 2026 12:20:31 +0300 Subject: [PATCH 08/16] Actually used a 'default' statement instead of that weird construct. Improved naming and clarity. --- mods/core/download_mods.rpy | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index eab8c94..de01858 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -153,27 +153,26 @@ init python: modconfig.steam_modlist_preloader.load() - from modmenu_search import sort_best + import modmenu_search import time def search_modlist(query): - # As renpy input doesn't allow for additional variables, I've had to resort to this cursed thing + # As renpy input doesn't allow for additional variables to this method, I've had to resort to this cursed thing curr_screen_scope = renpy.current_screen().scope - modlist = curr_screen_scope["contents"] page = curr_screen_scope["current_page"] page_size = curr_screen_scope["PAGE_SIZE"] use_steam = curr_screen_scope["use_steam"] s_time = time.time() - if query.strip(): - sorted_ml = sort_best(query, modlist) + if query.strip(): # There's no reason to reorder the modlist if no search has been done. + reordered_modlist = modmenu_search.sort_best(query, modlist) else: - sorted_ml = curr_screen_scope["contents"] - print "Search took: {:.5}".format(time.time() - s_time) + reordered_modlist = curr_screen_scope["contents"] + print "Search took: {:.5}".format(time.time() - s_time) # Hopefully this never goes above 0.3 - curr_screen_scope["ordered_contents"] = sorted_ml - _refresh_modlist_page(page, page_size, sorted_ml, use_steam) + curr_screen_scope["search_order_contents"] = reordered_modlist + _refresh_modlist_page(page, page_size, reordered_modlist, use_steam) renpy.restart_interaction() return @@ -419,9 +418,7 @@ screen modmenu_paged(contents, use_steam): $ MIN_PAGE = 1 # Do note, modpage numbers are 1-indexed $ MAX_PAGE = int(math.ceil(len(contents) / float(PAGE_SIZE))) - # ordered_contents used for searchbar sorting. as it needs to be set mainly from that function, we use this if node so that it only gets set here if missing - if not "ordered_contents" in renpy.current_screen().scope: - $ ordered_contents = contents + default search_order_contents = contents frame id "modmenu_paged" at alpha_dissolve: add "image/ui/ingame_menu_bg3.png" @@ -470,7 +467,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 # Tried to bind this to shift+scroll, but it didn't work... action [SetScreenVariable("current_page", max(current_page-5, MIN_PAGE)), - Function(_refresh_modlist_page, max(current_page-5, MIN_PAGE), PAGE_SIZE, ordered_contents, use_steam=use_steam) + Function(_refresh_modlist_page, max(current_page-5, MIN_PAGE), PAGE_SIZE, search_order_contents, use_steam=use_steam) ] sensitive (current_page > 1) @@ -479,7 +476,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 keysym "mousedown_4" action [SetScreenVariable("current_page", current_page-1), - Function(_refresh_modlist_page, current_page-1, PAGE_SIZE, ordered_contents, use_steam=use_steam) + Function(_refresh_modlist_page, current_page-1, PAGE_SIZE, search_order_contents, use_steam=use_steam) ] sensitive (current_page > 1) @@ -494,7 +491,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 keysym "mousedown_5" action [SetScreenVariable("current_page", current_page+1), - Function(_refresh_modlist_page, current_page+1, PAGE_SIZE, ordered_contents, use_steam=use_steam) + Function(_refresh_modlist_page, current_page+1, PAGE_SIZE, search_order_contents, use_steam=use_steam) ] sensitive (current_page < MAX_PAGE) @@ -503,7 +500,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 # Also tried to bind this to shift+scroll, but it didn't work... action [SetScreenVariable("current_page", min(current_page+5, MAX_PAGE)), - Function(_refresh_modlist_page, min(current_page+5, MAX_PAGE), PAGE_SIZE, ordered_contents, use_steam=use_steam) + Function(_refresh_modlist_page, min(current_page+5, MAX_PAGE), PAGE_SIZE, search_order_contents, use_steam=use_steam) ] sensitive (current_page < MAX_PAGE) From bbbf7746fe18023babdc54d30e42169f7e40d431 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Sat, 30 May 2026 15:39:48 +0300 Subject: [PATCH 09/16] Cleared the search cache on mod menu close --- mods/core/download_mods.rpy | 4 +++- mods/core/modules/modmenu_search.py | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index de01858..d127f86 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -519,7 +519,9 @@ screen modmenu_paged(contents, use_steam): ] on "hide" action [Function(mod_image_preloader.clear), # Cleanup after ourselves - Function(im.cache.clear)] + Function(im.cache.clear), + Function(modmenu_search.clear_cache), + ] diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 31922d1..62df791 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -3,11 +3,14 @@ def cache(function): def inner(*args): - if not hasattr(function, "results"): - function.results = {args: function(*args)} - elif args not in function.results: - function.results[args] = function(*args) - return function.results[args] + if not hasattr(inner, "results"): + inner.results = {args: function(*args)} + elif args not in inner.results: + inner.results[args] = function(*args) + return inner.results[args] + def clear_cache(): + inner.results.clear() + inner.clear_cache = clear_cache return inner # Copied from nltk (https://www.nltk.org/_modules/nltk/metrics/distance.html#jaro_similarity) @@ -142,3 +145,7 @@ def sort_best(query, modlist, return_score=False): else: return [mods_by_name[name] for name, _ in mod_order] + +def clear_cache(): + _jaro_best_match_cache.clear() + jaro_similarity.clear_cache() \ No newline at end of file From 260fc5a928f7afdb51409a562d4eedb0edaa263a Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Mon, 1 Jun 2026 18:58:30 +0300 Subject: [PATCH 10/16] Fixed modmenu_search.clear_cache crash when no searches have been done --- mods/core/modules/modmenu_search.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 62df791..dae935d 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -9,7 +9,8 @@ def inner(*args): inner.results[args] = function(*args) return inner.results[args] def clear_cache(): - inner.results.clear() + if hasattr(inner, "results"): + inner.results.clear() inner.clear_cache = clear_cache return inner From 468787fa1e1c833d65bc03d7ebe046badb1894e7 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Mon, 1 Jun 2026 18:58:35 +0300 Subject: [PATCH 11/16] Added a second input box for mod author name. Mod author search not implemented --- mods/core/download_mods.rpy | 99 ++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index d127f86..ac1fa9f 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -156,7 +156,22 @@ init python: import modmenu_search import time - def search_modlist(query): + def set_query(value): + curr_screen_scope = renpy.current_screen().scope + curr_screen_scope["query"] = value + search_modlist(value, curr_screen_scope["author_query"]) + return + + def set_author_query(value): + curr_screen_scope = renpy.current_screen().scope + curr_screen_scope["author_query"] = value + search_modlist(curr_screen_scope["query"], value) + return + + + def search_modlist(query, author_query=""): + print "searching with {}, {}".format(query, author_query) + # As renpy input doesn't allow for additional variables to this method, I've had to resort to this cursed thing curr_screen_scope = renpy.current_screen().scope modlist = curr_screen_scope["contents"] @@ -419,6 +434,8 @@ screen modmenu_paged(contents, use_steam): $ MAX_PAGE = int(math.ceil(len(contents) / float(PAGE_SIZE))) default search_order_contents = contents + default query = "" + default author_query = "" frame id "modmenu_paged" at alpha_dissolve: add "image/ui/ingame_menu_bg3.png" @@ -504,13 +521,81 @@ screen modmenu_paged(contents, use_steam): ] sensitive (current_page < MAX_PAGE) - input default "" changed search_modlist: - size 34 - color "#FFF000" - xpos 0.034 - ypos 0.05 + + hbox: + xpos 65 + ypos 10 xanchor 0.0 - yanchor 0.5 + yanchor 0.0 + + xsize 425 + ysize 74 + + spacing 10 + + vbox: + xalign 0.0 + ycenter 0.5 + xsize 75 + yfill True + spacing 6 + + label "author:": + text_size 24 + ysize 32 + xalign 0.0 + + label "mod:": + text_size 24 + ysize 32 + xalign 0.0 + + vbox: + xalign 0.0 + ycenter 0.5 + xfill True + yfill True + spacing 6 + + button: + background "#000000CD" + hover_background "#000040CD" + activate_sound None + action NullAction() + xfill True + ysize 32 + top_padding 5 + bottom_padding -5 + xpadding 0 + + input: + color "#FF7F00" + xalign 0.0 + ycenter 0.5 + size 24 + pixel_width 320 + changed set_author_query + + + + button: + background "#000000CD" + hover_background "#000040CD" + activate_sound None + action NullAction() + xfill True + ysize 32 + top_padding 5 + bottom_padding -5 + xpadding 0 + + input: + color "#FFFF00" + xalign 0.0 + ycenter 0.5 + size 24 + pixel_width 320 + changed set_query on "show" action [Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam), From 064740d26f04f5e11255d3cedb7de2bec4c1f6f0 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 2 Jun 2026 09:31:20 +0300 Subject: [PATCH 12/16] Visual and usability improvements to the search textboxes. textboxes receive focus by selection instead of by hovering. --- mods/core/download_mods.rpy | 44 +++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index ac1fa9f..2d1201c 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -527,9 +527,8 @@ screen modmenu_paged(contents, use_steam): ypos 10 xanchor 0.0 yanchor 0.0 - xsize 425 - ysize 74 + ysize 70 spacing 10 @@ -537,15 +536,17 @@ screen modmenu_paged(contents, use_steam): xalign 0.0 ycenter 0.5 xsize 75 - yfill True spacing 6 - label "author:": + # For some reason 'label' and 'text' text components insisted on being ever so slightly larger than necessary, which made everything look misaligned + textbutton "author:": + background "#00000000" text_size 24 ysize 32 xalign 0.0 - label "mod:": + textbutton "mod:": + background "#00000000" text_size 24 ysize 32 xalign 0.0 @@ -553,49 +554,50 @@ screen modmenu_paged(contents, use_steam): vbox: xalign 0.0 ycenter 0.5 - xfill True - yfill True spacing 6 + default query_input_capture_focus = False + default author_query_input_capture_focus = False + + # input components aggressively capture focus, to the point where you can't use more than one of them in a single screen. + # a button is used to circumvent this, as it is a container that can itself hold focus, so it is able to intercept the aggressive behaviour. button: - background "#000000CD" - hover_background "#000040CD" + background If(author_query_input_capture_focus, "#FFFFFFCD", "#000000CD") + hover_background If(author_query_input_capture_focus, "#BFBFFFCD", "#000040CD") activate_sound None - action NullAction() + key_events author_query_input_capture_focus + action [ToggleScreenVariable("author_query_input_capture_focus"), SetScreenVariable("query_input_capture_focus", False)] xfill True ysize 32 - top_padding 5 - bottom_padding -5 xpadding 0 input: - color "#FF7F00" + color If(author_query_input_capture_focus, "#000", "#FF7F00") xalign 0.0 ycenter 0.5 size 24 - pixel_width 320 + pixel_width 320 # While the horizontal space is supposed to be 340, The inputs have a tendency to drop down a row... changed set_author_query - button: - background "#000000CD" - hover_background "#000040CD" + background If(query_input_capture_focus, "#FFFFFFCD", "#000000CD") + hover_background If(query_input_capture_focus, "#BFBFFFCD", "#000040CD") activate_sound None - action NullAction() + key_events query_input_capture_focus + action [ToggleScreenVariable("query_input_capture_focus"), SetScreenVariable("author_query_input_capture_focus", False)] xfill True ysize 32 - top_padding 5 - bottom_padding -5 xpadding 0 input: - color "#FFFF00" + color If(query_input_capture_focus, "#000", "#FFFF00") xalign 0.0 ycenter 0.5 size 24 pixel_width 320 changed set_query + key "K_ESCAPE" action [SetScreenVariable("query_input_capture_focus", False), SetScreenVariable("author_query_input_capture_focus", False)] on "show" action [Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam), From 9ef1b208047451e5f577100d2608957c8c98ef91 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Thu, 4 Jun 2026 14:36:43 +0300 Subject: [PATCH 13/16] Author name search implemented. Tab can be used to switch between the input fields. --- mods/core/download_mods.rpy | 34 ++++++++++++++++------------- mods/core/modules/modmenu_search.py | 30 +++++++++++++++++++++---- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 2d1201c..5d75626 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -180,8 +180,8 @@ init python: use_steam = curr_screen_scope["use_steam"] s_time = time.time() - if query.strip(): # There's no reason to reorder the modlist if no search has been done. - reordered_modlist = modmenu_search.sort_best(query, modlist) + if query.strip() or author_query.strip(): # There's no reason to reorder the modlist if no search has been done. + reordered_modlist = modmenu_search.sort_best(query, modlist, author_query=author_query) else: reordered_modlist = curr_screen_scope["contents"] print "Search took: {:.5}".format(time.time() - s_time) # Hopefully this never goes above 0.3 @@ -556,23 +556,23 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 spacing 6 - default query_input_capture_focus = False - default author_query_input_capture_focus = False + default focus_query_input = False + default focus_author_query_input = False # input components aggressively capture focus, to the point where you can't use more than one of them in a single screen. # a button is used to circumvent this, as it is a container that can itself hold focus, so it is able to intercept the aggressive behaviour. button: - background If(author_query_input_capture_focus, "#FFFFFFCD", "#000000CD") - hover_background If(author_query_input_capture_focus, "#BFBFFFCD", "#000040CD") + background If(focus_author_query_input, "#FFFFFFCD", "#000000CD") + hover_background If(focus_author_query_input, "#BFBFFFCD", "#000040CD") activate_sound None - key_events author_query_input_capture_focus - action [ToggleScreenVariable("author_query_input_capture_focus"), SetScreenVariable("query_input_capture_focus", False)] + key_events focus_author_query_input + action [ToggleScreenVariable("focus_author_query_input"), SetScreenVariable("focus_query_input", False)] xfill True ysize 32 xpadding 0 input: - color If(author_query_input_capture_focus, "#000", "#FF7F00") + color If(focus_author_query_input, "#000", "#FF7F00") xalign 0.0 ycenter 0.5 size 24 @@ -581,23 +581,27 @@ screen modmenu_paged(contents, use_steam): button: - background If(query_input_capture_focus, "#FFFFFFCD", "#000000CD") - hover_background If(query_input_capture_focus, "#BFBFFFCD", "#000040CD") + background If(focus_query_input, "#FFFFFFCD", "#000000CD") + hover_background If(focus_query_input, "#BFBFFFCD", "#000040CD") activate_sound None - key_events query_input_capture_focus - action [ToggleScreenVariable("query_input_capture_focus"), SetScreenVariable("author_query_input_capture_focus", False)] + key_events focus_query_input + action [ToggleScreenVariable("focus_query_input"), SetScreenVariable("focus_author_query_input", False)] xfill True ysize 32 xpadding 0 input: - color If(query_input_capture_focus, "#000", "#FFFF00") + color If(focus_query_input, "#000", "#FFFF00") xalign 0.0 ycenter 0.5 size 24 pixel_width 320 changed set_query - key "K_ESCAPE" action [SetScreenVariable("query_input_capture_focus", False), SetScreenVariable("author_query_input_capture_focus", False)] + key "K_ESCAPE" action [SetScreenVariable("focus_query_input", False), SetScreenVariable("focus_author_query_input", False)] + key "K_TAB" action [ToggleScreenVariable("focus_author_query_input"), + If(focus_author_query_input, + ToggleScreenVariable("focus_query_input"), + SetScreenVariable("focus_query_input", False))] on "show" action [Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam), diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index dae935d..2e4ee93 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -129,15 +129,37 @@ def jaro_split_compare(query, modlist): return comps +def jaro_author_compare(author_query, modlist): + comps = {} + author_query = author_query.lower() + + for _, name, author, _, _ in modlist: + comps[name] = jaro_similarity(author_query, author.lower()) + + return comps -def sort_best(query, modlist, return_score=False): + +def sort_best(query, modlist, author_query="", return_score=False): """Sort mods by best match to query""" - similarities = jaro_split_compare(query, modlist) + if query.strip(): + similarities = jaro_split_compare(query, modlist) + else: + similarities = {name: (0.0, 0.0) for _, name, _, _, _ in modlist} + if author_query.strip(): + author_similarities = jaro_author_compare(author_query, modlist) + else: + author_similarities = {name: 0.0 for name in similarities.iterkeys()} + similarities = {name: scores + (author_similarities[name],) for name, scores in similarities.iteritems()} # Much easier to deal with if it's a single iterable - # Sort by best match, with bias to strong modname matches + # Sort by best match, with bias to strong modname matches and strong authorname matches # This bias is useful as the description normally takes the stronger value, unless the mod name is searched specifically. # Max gave me better results than sum, so I used it. - mod_order = [entry for entry in sorted(similarities.items(), key=lambda e: (max(e[1]) + int(e[1][0] > 0.9) * e[1][0]), reverse=True)] + comp_func = lambda e: (max(e[1][:2]) + (int(e[1][0] > 0.9) * e[1][0]) + (int(e[1][2] > 0.7) * e[1][2])) + mod_order = list(sorted(similarities.items(), key=comp_func, reverse=True)) + + if comp_func(mod_order[0]) <= 0.3: # All bad matches, don't reorder + print "No good matches. reordering suppressed" + mod_order = list((name, 0.0) for _, name, _, _, _ in modlist) mods_by_name = {mod[1]: mod for mod in modlist} From b56155c1e91c5ec752367c608d97411a20871b1e Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Thu, 4 Jun 2026 14:43:37 +0300 Subject: [PATCH 14/16] search function transitioned from collections.Counters to sets, as word counts haven't actually been used --- mods/core/modules/modmenu_search.py | 33 +++++++++++++---------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 2e4ee93..52030bb 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -1,5 +1,3 @@ -import collections - def cache(function): def inner(*args): @@ -81,39 +79,38 @@ def jaro_similarity(s1, s2): _jaro_best_match_cache = {} -def jaro_counter_similarity(query_counter, target1, target2): - """Finds approximate similarity between word counter query_counter and target word strings target1 and target2. - each word in query_counter is compared against all of target1's and target2's words to find the best match. - the similarity is then the weighted average of each of those best similarity numbers, weighted by word count in query_counter. - these results are cached by target1 for each word of query_counter, and as such, for each value of target1 there should only be a single value of target2.""" +def jaro_set_similarity(query_set, target1, target2): + """Finds approximate similarity between word set query_set and target word strings target1 and target2. + each word in query_set is compared against all of target1's and target2's words to find the best match. + the similarity is then the average of each of those best similarity numbers. + these results are cached by target1 for each word of query_set, and as such, for each value of target1 there should only be a single value of target2.""" best_similarity_1 = {} best_similarity_2 = {} - for query_word in query_counter.iterkeys(): + for query_word in query_set: if (query_word, target1) not in _jaro_best_match_cache: - t1_counter = collections.Counter(target1.lower().split()) + t1_word_set = set(target1.lower().split()) curr_best_1 = 0 - for target_word in t1_counter.iterkeys(): + for target_word in t1_word_set: curr_best_1 = max(curr_best_1, jaro_similarity(query_word, target_word)) best_similarity_1[query_word] = curr_best_1 - t2_counter = collections.Counter(target2.lower().split()) + t2_word_set = set(target2.lower().split()) curr_best_2 = 0 - for target_word in t2_counter.iterkeys(): + for target_word in t2_word_set: curr_best_2 = max(curr_best_2, jaro_similarity(query_word, target_word)) best_similarity_2[query_word] = curr_best_2 - _jaro_best_match_cache[(query_word, target1)] = (curr_best_1, curr_best_2) else: sim1, sim2 = _jaro_best_match_cache[(query_word, target1)] best_similarity_1[query_word] = sim1 best_similarity_2[query_word] = sim2 - n_values = sum(query_counter.itervalues()) - return (sum(best_similarity_1[query_word] * count for query_word, count in query_counter.iteritems()) / n_values, - sum(best_similarity_2[query_word] * count for query_word, count in query_counter.iteritems()) / n_values) + n_values = len(query_set) + return (sum(best_similarity_1[query_word] for query_word in query_set) / n_values, + sum(best_similarity_2[query_word] for query_word in query_set) / n_values) @@ -122,10 +119,10 @@ def jaro_split_compare(query, modlist): :returns dict from modname to similarity tuple, which contains the similarity of query to modname, then the similarity of query to mod description. """ comps = {} - query_words = collections.Counter(query.lower().split()) + query_words = set(query.lower().split()) for _, name, _, desc, _ in modlist: - comps[name] = jaro_counter_similarity(query_words, name, desc) + comps[name] = jaro_set_similarity(query_words, name, desc) return comps From 0f8c1d7ea488bb0f482fb43bdc39e21d876275af Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Fri, 3 Jul 2026 12:46:58 +0300 Subject: [PATCH 15/16] Fixed capitalization --- mods/core/download_mods.rpy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 5d75626..76cf370 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -539,13 +539,13 @@ screen modmenu_paged(contents, use_steam): spacing 6 # For some reason 'label' and 'text' text components insisted on being ever so slightly larger than necessary, which made everything look misaligned - textbutton "author:": + textbutton "Author:": background "#00000000" text_size 24 ysize 32 xalign 0.0 - textbutton "mod:": + textbutton "Mod:": background "#00000000" text_size 24 ysize 32 From 032e787da6ea8f8c990e46484ff021153b20653c Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Sun, 12 Jul 2026 11:52:24 +0300 Subject: [PATCH 16/16] nltk license compatibility --- NLTK_LICENSE.txt | 205 ++++++++++++++++++++++++++++ mods/core/modules/modmenu_search.py | 19 ++- 2 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 NLTK_LICENSE.txt diff --git a/NLTK_LICENSE.txt b/NLTK_LICENSE.txt new file mode 100644 index 0000000..a3d8514 --- /dev/null +++ b/NLTK_LICENSE.txt @@ -0,0 +1,205 @@ +This is a copy of the license of the nltk project (), as is required under section 4. Redistribution. +It is particularly relevant for 'mods/core/modules/modmenu_search.py', and for any other instances of nltk code in this project. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 52030bb..9ca8256 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -1,4 +1,18 @@ +# This file contains function definitions from nltk repository, under the Apache License Version 2.0 +# in particular, the function jaro_similarity has been used. +# Those have been used under these attributions: +# +# Copyright (C) 2001-2025 NLTK Project +# Author: Edward Loper +# Steven Bird +# Tom Lippincott +# URL: +# For attribution license information, see NLTK_LICENSE.txt +# +# All components which are not explicitly licensed under the Apache License Version 2.0 of nltk (NLTK_LICENSE.txt) are licensed under this project's standard license (LICENSE) +# + def cache(function): def inner(*args): if not hasattr(inner, "results"): @@ -13,6 +27,7 @@ def clear_cache(): return inner # Copied from nltk (https://www.nltk.org/_modules/nltk/metrics/distance.html#jaro_similarity) +# This function is copied from nltk, and is therefore licensed under their Apache License Version 2.0 (NLTK_LICENSE.txt) @cache def jaro_similarity(s1, s2): """ @@ -64,9 +79,9 @@ def jaro_similarity(s1, s2): if matches == 0: return 0 else: - matches = float(matches) + matches = float(matches) # Added to nltk's implementation. int division and float division are qualitatively different in python 2 (unlike python 3, where nltk is implemented), and the float division is the intended behaviour. return ( - 1 / 3.0 + 1 / 3.0 # Changed from nltk's 3 to 3.0, for the same reason as the last change. * ( matches / len_s1 + matches / len_s2