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}