|
| 1 | +using Newtonsoft.Json; |
| 2 | +using System; |
| 3 | +using System.Collections.Generic; |
| 4 | +using System.Globalization; |
| 5 | +using System.IO; |
| 6 | +using System.Linq; |
| 7 | +using System.Net.Http; |
| 8 | +using System.Text; |
| 9 | +using System.Text.RegularExpressions; |
| 10 | + |
| 11 | +namespace Olympus { |
| 12 | + // https://github.com/maddie480/RandomStuffWebsite/blob/main/src/main/java/ovh/maddie480/randomstuff/frontend/CelesteModSearchService.java |
| 13 | + // but it's entirely client-side because some poor souls can't access maddie480.ovh (yay!) |
| 14 | + |
| 15 | + class GameBananaAPIEmulator { |
| 16 | + private static List<Dictionary<string, object>> everything; |
| 17 | + public static List<Dictionary<string, object>> Get() { |
| 18 | + if (everything == null) { |
| 19 | + using (HttpClient client = new HttpClientWithCompressionSupport()) |
| 20 | + using (Stream inputStream = client.GetAsync("https://everestapi.github.io/updatermirror/mod_search_database.yaml").Result.Content.ReadAsStream()) |
| 21 | + using (TextReader reader = new StreamReader(inputStream)) { |
| 22 | + everything = YamlHelper.Deserializer.Deserialize<List<Dictionary<string, object>>>(reader); |
| 23 | + } |
| 24 | + } |
| 25 | + |
| 26 | + return everything; |
| 27 | + } |
| 28 | + } |
| 29 | + |
| 30 | + public class CmdEmulatedModList : Cmd<string, int, string, int?, int?, string> { |
| 31 | + public override string Run(string sort, int page, string type, int? category, int? subcategory) { |
| 32 | + // is there a type and/or a category filter? |
| 33 | + List<Predicate<Dictionary<string, object>>> typeFilters = new List<Predicate<Dictionary<string, object>>>(); |
| 34 | + if (type != null) { |
| 35 | + typeFilters.Add(info => type.Equals(info["GameBananaType"])); |
| 36 | + } |
| 37 | + if (category != null) { |
| 38 | + typeFilters.Add(info => category == int.Parse((string) info["CategoryId"])); |
| 39 | + } |
| 40 | + if (subcategory != null) { |
| 41 | + typeFilters.Add(info => info.ContainsKey("SubcategoryId") && subcategory == int.Parse((string) info["SubcategoryId"])); |
| 42 | + } |
| 43 | + // typeFilter is a && of all typeFilters |
| 44 | + Predicate<Dictionary<string, object>> typeFilter = info => typeFilters.All(filter => filter(info)); |
| 45 | + |
| 46 | + // determine the field on which we want to sort. Sort by descending id to tell equal values apart. |
| 47 | + IComparer<Dictionary<string, object>> sortComparator; |
| 48 | + switch (sort) { |
| 49 | + case "views": |
| 50 | + sortComparator = new Sorter("Views"); |
| 51 | + break; |
| 52 | + case "likes": |
| 53 | + sortComparator = new Sorter("Likes"); |
| 54 | + break; |
| 55 | + case "downloads": |
| 56 | + sortComparator = new Sorter("Downloads"); |
| 57 | + break; |
| 58 | + default: |
| 59 | + sortComparator = new Sorter("CreatedDate"); |
| 60 | + break; |
| 61 | + } |
| 62 | + |
| 63 | + // then sort on it. |
| 64 | + List<Dictionary<string, object>> response = GameBananaAPIEmulator.Get() |
| 65 | + .Where(d => typeFilter.Invoke(d)) |
| 66 | + .OrderBy(d => d, sortComparator) |
| 67 | + .Skip((page - 1) * 20) |
| 68 | + .Take(20) |
| 69 | + .ToList(); |
| 70 | + |
| 71 | + return JsonConvert.SerializeObject(response); |
| 72 | + } |
| 73 | + |
| 74 | + private class Sorter(string field) : IComparer<Dictionary<string, object>> { |
| 75 | + public int Compare(Dictionary<string, object> x, Dictionary<string, object> y) { |
| 76 | + int diff = int.Parse((string) y[field]) - int.Parse((string) x[field]); |
| 77 | + if (diff != 0) return diff; |
| 78 | + return int.Parse((string) x["GameBananaId"]) - int.Parse((string) y["GameBananaId"]); |
| 79 | + } |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + public partial class CmdEmulatedModSearch : Cmd<string, string> { |
| 84 | + public override string Run(string query) { |
| 85 | + string[] tokenizedRequest = tokenize(query); |
| 86 | + |
| 87 | + List<Dictionary<string, object>> response = GameBananaAPIEmulator.Get() |
| 88 | + .Select(m => new Tuple<Dictionary<string, object>, double>(m, scoreMod(tokenizedRequest, tokenize((string) m["Name"])))) |
| 89 | + .Where(m => m.Item2 > 0.2 * tokenizedRequest.Length) |
| 90 | + .OrderBy(m => m, new Sorter()) |
| 91 | + .Select(m => m.Item1) |
| 92 | + .Take(20) |
| 93 | + .ToList(); |
| 94 | + |
| 95 | + return JsonConvert.SerializeObject(response); |
| 96 | + } |
| 97 | + |
| 98 | + private class Sorter : IComparer<Tuple<Dictionary<string, object>, double>> { |
| 99 | + public int Compare(Tuple<Dictionary<string, object>, double> x, Tuple<Dictionary<string, object>, double> y) { |
| 100 | + double diff = y.Item2 - x.Item2; |
| 101 | + if (diff != 0) return Math.Sign(diff); |
| 102 | + return int.Parse((string) y.Item1["Downloads"]) - int.Parse((string) x.Item1["Downloads"]); |
| 103 | + } |
| 104 | + } |
| 105 | + |
| 106 | + private static double scoreMod(string[] query, string[] modName) { |
| 107 | + double score = 0; |
| 108 | + |
| 109 | + foreach (string tokenSearch in query) { |
| 110 | + if (tokenSearch.EndsWith('*')) { |
| 111 | + // "starts with" search: add 1 if there's a word starting with the prefix |
| 112 | + string tokenSearchStart = tokenSearch.Substring(0, tokenSearch.Length - 1); |
| 113 | + foreach (string tokenModName in modName) { |
| 114 | + if (tokenModName.StartsWith(tokenSearchStart)) { |
| 115 | + score++; |
| 116 | + break; |
| 117 | + } |
| 118 | + } |
| 119 | + } else { |
| 120 | + // "equals" search: take the score of the word that is closest to the token |
| 121 | + double tokenScore = 0; |
| 122 | + foreach (string tokenModName in modName) { |
| 123 | + tokenScore = Math.Max(tokenScore, Math.Pow(0.5, levenshteinDistance(tokenSearch, tokenModName))); |
| 124 | + } |
| 125 | + score += tokenScore; |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + return score; |
| 130 | + } |
| 131 | + |
| 132 | + private static string[] tokenize(string s) { |
| 133 | + s = removeDiacritics(s.ToLowerInvariant()) // "Pokémon" => "pokemon" |
| 134 | + .Replace("'", ""); // "Maddie's Helping Hand" => "maddies helping hand" |
| 135 | + s = notDigitOrLetter().Replace(s, " "); // "The D-Sides Pack" => "the d sides pack" |
| 136 | + while (s.Contains(" ")) s = s.Replace(" ", " "); |
| 137 | + return s.Split(" "); |
| 138 | + } |
| 139 | + |
| 140 | + // Source - https://stackoverflow.com/a/249126 |
| 141 | + // Posted by Blair Conrad, modified by community. See post 'Timeline' for change history |
| 142 | + // Retrieved 2025-11-09, License - CC BY-SA 4.0 |
| 143 | + private static string removeDiacritics(string text) { |
| 144 | + var normalizedString = text.Normalize(NormalizationForm.FormD); |
| 145 | + var stringBuilder = new StringBuilder(capacity: normalizedString.Length); |
| 146 | + |
| 147 | + for (int i = 0; i < normalizedString.Length; i++) { |
| 148 | + char c = normalizedString[i]; |
| 149 | + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); |
| 150 | + if (unicodeCategory != UnicodeCategory.NonSpacingMark) { |
| 151 | + stringBuilder.Append(c); |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + return stringBuilder |
| 156 | + .ToString() |
| 157 | + .Normalize(NormalizationForm.FormC); |
| 158 | + } |
| 159 | + |
| 160 | + // Source - https://www.dotnetperls.com/levenshtein |
| 161 | + private static int levenshteinDistance(string s, string t) { |
| 162 | + int n = s.Length; |
| 163 | + int m = t.Length; |
| 164 | + int[,] d = new int[n + 1, m + 1]; |
| 165 | + |
| 166 | + // Verify arguments. |
| 167 | + if (n == 0) { |
| 168 | + return m; |
| 169 | + } |
| 170 | + if (m == 0) { |
| 171 | + return n; |
| 172 | + } |
| 173 | + |
| 174 | + // Initialize arrays. |
| 175 | + for (int i = 0; i <= n; d[i, 0] = i++) { |
| 176 | + } |
| 177 | + for (int j = 0; j <= m; d[0, j] = j++) { |
| 178 | + } |
| 179 | + |
| 180 | + // Begin looping. |
| 181 | + for (int i = 1; i <= n; i++) { |
| 182 | + for (int j = 1; j <= m; j++) { |
| 183 | + // Compute cost. |
| 184 | + int cost = (t[j - 1] == s[i - 1]) ? 0 : 1; |
| 185 | + d[i, j] = Math.Min( |
| 186 | + Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), |
| 187 | + d[i - 1, j - 1] + cost); |
| 188 | + } |
| 189 | + } |
| 190 | + // Return cost. |
| 191 | + return d[n, m]; |
| 192 | + } |
| 193 | + |
| 194 | + [GeneratedRegex("[^a-z0-9* ]")] |
| 195 | + private static partial Regex notDigitOrLetter(); |
| 196 | + } |
| 197 | +} |
0 commit comments