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/download_mods.rpy b/mods/core/download_mods.rpy index 8cc6714..76cf370 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -153,6 +153,46 @@ init python: modconfig.steam_modlist_preloader.load() + import modmenu_search + import time + + 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"] + 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() 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 + + curr_screen_scope["search_order_contents"] = reordered_modlist + _refresh_modlist_page(page, page_size, reordered_modlist, use_steam) + renpy.restart_interaction() + + return + + init -1 python: import sys import math @@ -393,6 +433,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))) + default search_order_contents = contents + default query = "" + default author_query = "" + frame id "modmenu_paged" at alpha_dissolve: add "image/ui/ingame_menu_bg3.png" @@ -440,7 +484,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, search_order_contents, use_steam=use_steam) ] sensitive (current_page > 1) @@ -449,7 +493,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, search_order_contents, use_steam=use_steam) ] sensitive (current_page > 1) @@ -464,7 +508,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, search_order_contents, use_steam=use_steam) ] sensitive (current_page < MAX_PAGE) @@ -473,14 +517,102 @@ 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, search_order_contents, use_steam=use_steam) ] sensitive (current_page < MAX_PAGE) - on "show" action [Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam), - Function(_preload_mod_images, contents, None)] - on "hide" action [Function(mod_image_preloader.clear)] + hbox: + xpos 65 + ypos 10 + xanchor 0.0 + yanchor 0.0 + xsize 425 + ysize 70 + + spacing 10 + + vbox: + xalign 0.0 + ycenter 0.5 + xsize 75 + 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:": + background "#00000000" + text_size 24 + ysize 32 + xalign 0.0 + + textbutton "Mod:": + background "#00000000" + text_size 24 + ysize 32 + xalign 0.0 + + vbox: + xalign 0.0 + ycenter 0.5 + spacing 6 + + 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(focus_author_query_input, "#FFFFFFCD", "#000000CD") + hover_background If(focus_author_query_input, "#BFBFFFCD", "#000040CD") + activate_sound None + 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(focus_author_query_input, "#000", "#FF7F00") + xalign 0.0 + ycenter 0.5 + size 24 + 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 If(focus_query_input, "#FFFFFFCD", "#000000CD") + hover_background If(focus_query_input, "#BFBFFFCD", "#000040CD") + activate_sound None + 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(focus_query_input, "#000", "#FFFF00") + xalign 0.0 + ycenter 0.5 + size 24 + pixel_width 320 + changed set_query + 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), + 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), # Cleanup after ourselves + 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 new file mode 100644 index 0000000..9ca8256 --- /dev/null +++ b/mods/core/modules/modmenu_search.py @@ -0,0 +1,186 @@ + +# 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"): + inner.results = {args: function(*args)} + elif args not in inner.results: + inner.results[args] = function(*args) + return inner.results[args] + def clear_cache(): + if hasattr(inner, "results"): + inner.results.clear() + inner.clear_cache = 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): + """ + 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 = 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 + 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) # 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 # Changed from nltk's 3 to 3.0, for the same reason as the last change. + * ( + matches / len_s1 + + matches / len_s2 + + (matches - int(transpositions / 2)) / matches + ) + ) + + + +_jaro_best_match_cache = {} + + +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_set: + if (query_word, target1) not in _jaro_best_match_cache: + t1_word_set = set(target1.lower().split()) + curr_best_1 = 0 + 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_word_set = set(target2.lower().split()) + curr_best_2 = 0 + 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 = 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) + + + +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 = set(query.lower().split()) + + for _, name, _, desc, _ in modlist: + comps[name] = jaro_set_similarity(query_words, name, desc) + + 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, author_query="", return_score=False): + """Sort mods by best match to query""" + 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 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. + 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} + + 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] + + +def clear_cache(): + _jaro_best_match_cache.clear() + jaro_similarity.clear_cache() \ No newline at end of file