the browser-facing portion of osu!
at master 9.0 kB view raw
1<?php 2 3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4// See the LICENCE file in the repository root for full licence text. 5 6namespace App\Libraries\Search; 7 8use App\Models\Beatmapset; 9use Carbon\CarbonImmutable; 10 11class BeatmapsetQueryParser 12{ 13 public static function parse(?string $query): array 14 { 15 $options = []; 16 17 // reference: https://github.com/ppy/osu/blob/f6baf49ad6b42c662a729ad05e18bd99bc48b4c7/osu.Game/Screens/Select/FilterQueryParser.cs 18 $keywords = preg_replace_callback('#\b(?<key>\w+)(?<op>(:|=|(>|<)(:|=)?))(?<value>(".*")|(\S*))#i', function ($m) use (&$options) { 19 $key = strtolower($m['key']); 20 $op = str_replace(':', '=', $m['op']); 21 switch ($key) { 22 case 'star': 23 case 'stars': 24 $key = 'stars'; 25 $option = static::makeFloatRangeOption($op, $m['value'], 0.01 / 2); 26 break; 27 case 'ar': 28 $option = static::makeFloatRangeOption($op, $m['value'], 0.1 / 2); 29 break; 30 case 'dr': 31 case 'hp': 32 $key = 'dr'; 33 $option = static::makeFloatRangeOption($op, $m['value'], 0.1 / 2); 34 break; 35 case 'cs': 36 $option = static::makeFloatRangeOption($op, $m['value'], 0.1 / 2); 37 break; 38 case 'od': 39 $option = static::makeFloatRangeOption($op, $m['value'], 0.1 / 2); 40 break; 41 case 'bpm': 42 $option = static::makeFloatRangeOption($op, $m['value'], 0.01 / 2); 43 break; 44 case 'circles': 45 $option = static::makeIntRangeOption($op, $m['value']); 46 break; 47 case 'sliders': 48 $option = static::makeIntRangeOption($op, $m['value']); 49 break; 50 case 'length': 51 $parsed = get_length_seconds($m['value']); 52 if ($parsed !== null) { 53 $option = static::makeFloatRangeOption($op, $parsed['value'], $parsed['min_scale'] / 2); 54 } 55 break; 56 case 'featured_artist': 57 $option = static::makeIntOption($op, $m['value']); 58 break; 59 case 'key': 60 case 'keys': 61 $key = 'keys'; 62 $option = static::makeIntRangeOption($op, $m['value']); 63 break; 64 case 'divisor': 65 $option = static::makeIntRangeOption($op, $m['value']); 66 break; 67 case 'status': 68 $option = static::makeIntRangeOption($op, static::statePrefixSearch($m['value'])); 69 break; 70 case 'creator': 71 $option = static::makeTextOption($op, $m['value']); 72 break; 73 case 'difficulty': 74 $option = static::makeTextOption($op, $m['value']); 75 break; 76 case 'favourites': 77 $option = static::makeIntRangeOption($op, $m['value']); 78 break; 79 case 'artist': 80 $option = static::makeTextOption($op, $m['value']); 81 break; 82 case 'source': 83 $option = static::makeTextOption($op, $m['value']); 84 break; 85 case 'title': 86 $option = static::makeTextOption($op, $m['value']); 87 break; 88 case 'created': 89 $option = static::makeDateRangeOption($op, $m['value']); 90 break; 91 case 'ranked': 92 $option = static::makeDateRangeOption($op, $m['value']); 93 break; 94 case 'updated': 95 $option = static::makeDateRangeOption($op, $m['value']); 96 break; 97 } 98 99 if (isset($option)) { 100 if (is_array($option)) { 101 $options[$key] = array_merge($options[$key] ?? [], $option); 102 } else { 103 $options[$key] = $option; 104 } 105 106 return ''; 107 } 108 109 return $m[0]; 110 }, $query ?? ''); 111 112 return [ 113 'keywords' => presence(trim($keywords)), 114 'options' => $options, 115 ]; 116 } 117 118 private static function makeDateRangeOption(string $operator, string $value): ?array 119 { 120 $value = presence(trim($value, '"')); 121 122 if (preg_match('#^\d{4}$#', $value) === 1) { 123 $startTime = CarbonImmutable::create($value, 1, 1, 0, 0, 0, 'UTC'); 124 $endTime = $startTime->addYears(1); 125 } elseif (preg_match('#^(?<year>\d{4})[-./]?(?<month>\d{1,2})$#', $value, $m) === 1) { 126 $startTime = CarbonImmutable::create($m['year'], $m['month'], 1, 0, 0, 0, 'UTC'); 127 $endTime = $startTime->addMonths(1); 128 } elseif (preg_match('#^(?<year>\d{4})[-./]?(?<month>\d{1,2})[-./]?(?<day>\d{1,2})$#', $value, $m) === 1) { 129 $startTime = CarbonImmutable::create($m['year'], $m['month'], $m['day'], 0, 0, 0, 'UTC'); 130 $endTime = $startTime->addDays(1); 131 } else { 132 $startTime = parse_time_to_carbon($value)?->toImmutable()->utc(); 133 $endTime = $startTime?->addSeconds(1); 134 } 135 136 if (isset($startTime) && isset($endTime)) { 137 return match ($operator) { 138 '=' => [ 139 'gte' => $startTime->getTimestampMs(), 140 'lt' => $endTime->getTimestampMs(), 141 ], 142 '<' => [ 143 'lt' => $startTime->getTimestampMs(), 144 ], 145 '<=' => [ 146 'lt' => $endTime->getTimestampMs(), 147 ], 148 '>' => [ 149 'gte' => $endTime->getTimestampMs(), 150 ], 151 '>=' => [ 152 'gte' => $startTime->getTimestampMs(), 153 ], 154 }; 155 } 156 157 return null; 158 } 159 160 private static function makeFloatRangeOption($operator, $value, $tolerance) 161 { 162 // Some locales have `,` as decimal separator. 163 // Note that thousand separator is not (yet?) supported. 164 $value = str_replace(',', '.', $value); 165 166 if (!is_numeric($value)) { 167 return; 168 } 169 170 $value = get_float($value); 171 172 switch ($operator) { 173 case '=': 174 return [ 175 'gte' => $value - $tolerance, 176 'lte' => $value + $tolerance, 177 ]; 178 case '<': 179 return [ 180 'lte' => $value - $tolerance, 181 ]; 182 case '<=': 183 return [ 184 'lte' => $value + $tolerance, 185 ]; 186 case '>': 187 return [ 188 'gte' => $value + $tolerance, 189 ]; 190 case '>=': 191 return [ 192 'gte' => $value - $tolerance, 193 ]; 194 } 195 } 196 197 private static function makeIntOption($operator, $value) 198 { 199 if (is_numeric($value) && $operator === '=') { 200 return get_int($value); 201 } 202 } 203 204 private static function makeIntRangeOption($operator, $value) 205 { 206 if (!is_numeric($value)) { 207 return; 208 } 209 210 $value = get_int($value); 211 212 switch ($operator) { 213 case '=': 214 return [ 215 'gte' => $value, 216 'lte' => $value, 217 ]; 218 case '<': 219 return [ 220 'lt' => $value, 221 ]; 222 case '<=': 223 return [ 224 'lte' => $value, 225 ]; 226 case '>': 227 return [ 228 'gt' => $value, 229 ]; 230 case '>=': 231 return [ 232 'gte' => $value, 233 ]; 234 } 235 } 236 237 private static function makeTextOption(string $operator, string $value): ?string 238 { 239 return $operator === '=' 240 ? presence(preg_replace('/^"(.*)"$/', '$1', $value)) 241 : null; 242 } 243 244 private static function statePrefixSearch($value): ?int 245 { 246 if (!present($value)) { 247 return null; 248 } 249 250 if (isset(Beatmapset::STATES[$value])) { 251 return Beatmapset::STATES[$value]; 252 } 253 254 foreach (Beatmapset::STATES as $string => $int) { 255 if (starts_with($string, $value)) { 256 return $int; 257 } 258 } 259 260 return null; 261 } 262}