Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
52.27% |
115 / 220 |
|
15.91% |
7 / 44 |
CRAP | |
0.00% |
0 / 1 |
SearchEngine | |
52.27% |
115 / 220 |
|
15.91% |
7 / 44 |
1076.18 | |
0.00% |
0 / 1 |
searchText | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
doSearchText | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
searchArchiveTitle | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
doSearchArchiveTitle | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
searchTitle | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
doSearchTitle | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
maybePaginate | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
supports | |
50.00% |
2 / 4 |
|
0.00% |
0 / 1 |
6.00 | |||
setFeatureData | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getFeatureData | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
normalizeText | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getNearMatcher | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
defaultNearMatcher | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
legalSearchChars | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setLimitOffset | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setNamespaces | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
setShowSuggestion | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getValidSorts | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setSort | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getSort | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
replacePrefixes | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
parseNamespacePrefixes | |
96.97% |
32 / 33 |
|
0.00% |
0 / 1 |
11 | |||
userHighlightPrefs | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
update | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
updateTitle | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
delete | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getTextFromContent | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
textAlreadyUpdatedForIndex | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
normalizeNamespaces | |
60.00% |
3 / 5 |
|
0.00% |
0 / 1 |
2.26 | |||
completionSearchBackendOverfetch | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
completionSearchBackend | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
3.02 | |||
completionSearch | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
completionSearchWithVariants | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
30 | |||
extractTitles | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
processCompletionResults | |
78.79% |
26 / 33 |
|
0.00% |
0 / 1 |
7.47 | |||
defaultPrefixSearch | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
simplePrefixSearch | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getProfiles | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
makeSearchFieldMapping | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getSearchIndexFields | |
81.82% |
18 / 22 |
|
0.00% |
0 / 1 |
7.29 | |||
augmentSearchResults | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
56 | |||
setHookContainer | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getHookContainer | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getHookRunner | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 |
1 | <?php |
2 | /** |
3 | * Basic search engine |
4 | * |
5 | * This program is free software; you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation; either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License along |
16 | * with this program; if not, write to the Free Software Foundation, Inc., |
17 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
18 | * http://www.gnu.org/copyleft/gpl.html |
19 | * |
20 | * @file |
21 | * @ingroup Search |
22 | */ |
23 | |
24 | /** |
25 | * @defgroup Search Search |
26 | */ |
27 | |
28 | use MediaWiki\Config\Config; |
29 | use MediaWiki\HookContainer\HookContainer; |
30 | use MediaWiki\HookContainer\HookRunner; |
31 | use MediaWiki\MediaWikiServices; |
32 | use MediaWiki\Search\TitleMatcher; |
33 | use MediaWiki\Status\Status; |
34 | use MediaWiki\Title\Title; |
35 | use MediaWiki\User\User; |
36 | |
37 | /** |
38 | * Contain a class for special pages |
39 | * @stable to extend |
40 | * @ingroup Search |
41 | */ |
42 | abstract class SearchEngine { |
43 | public const DEFAULT_SORT = 'relevance'; |
44 | |
45 | /** @var string */ |
46 | public $prefix = ''; |
47 | |
48 | /** @var int[]|null */ |
49 | public $namespaces = [ NS_MAIN ]; |
50 | |
51 | /** @var int */ |
52 | protected $limit = 10; |
53 | |
54 | /** @var int */ |
55 | protected $offset = 0; |
56 | |
57 | /** |
58 | * @var string[] |
59 | * @deprecated since 1.34 |
60 | */ |
61 | protected $searchTerms = []; |
62 | |
63 | /** @var bool */ |
64 | protected $showSuggestion = true; |
65 | private $sort = self::DEFAULT_SORT; |
66 | |
67 | /** @var array Feature values */ |
68 | protected $features = []; |
69 | |
70 | /** @var HookContainer */ |
71 | private $hookContainer; |
72 | |
73 | /** @var HookRunner */ |
74 | private $hookRunner; |
75 | |
76 | /** Profile type for completionSearch */ |
77 | public const COMPLETION_PROFILE_TYPE = 'completionSearchProfile'; |
78 | |
79 | /** Profile type for query independent ranking features */ |
80 | public const FT_QUERY_INDEP_PROFILE_TYPE = 'fulltextQueryIndepProfile'; |
81 | |
82 | /** Integer flag for legalSearchChars: includes all chars allowed in a search query */ |
83 | protected const CHARS_ALL = 1; |
84 | |
85 | /** Integer flag for legalSearchChars: includes all chars allowed in a search term */ |
86 | protected const CHARS_NO_SYNTAX = 2; |
87 | |
88 | /** |
89 | * Perform a full text search query and return a result set. |
90 | * If full text searches are not supported or disabled, return null. |
91 | * |
92 | * @note As of 1.32 overriding this function is deprecated. It will |
93 | * be converted to final in 1.34. Override self::doSearchText(). |
94 | * |
95 | * @param string $term Raw search term |
96 | * @return ISearchResultSet|Status|null |
97 | */ |
98 | public function searchText( $term ) { |
99 | return $this->maybePaginate( function () use ( $term ) { |
100 | return $this->doSearchText( $term ); |
101 | } ); |
102 | } |
103 | |
104 | /** |
105 | * Perform a full text search query and return a result set. |
106 | * |
107 | * @stable to override |
108 | * |
109 | * @param string $term Raw search term |
110 | * @return ISearchResultSet|Status|null |
111 | * @since 1.32 |
112 | */ |
113 | protected function doSearchText( $term ) { |
114 | return null; |
115 | } |
116 | |
117 | /** |
118 | * Perform a title search in the article archive. |
119 | * NOTE: these results still should be filtered by |
120 | * matching against PageArchive, permissions checks etc |
121 | * The results returned by this methods are only suggestions and |
122 | * may not end up being shown to the user. |
123 | * |
124 | * @note As of 1.32 overriding this function is deprecated. It will |
125 | * be converted to final in 1.34. Override self::doSearchArchiveTitle(). |
126 | * |
127 | * @param string $term Raw search term |
128 | * @return Status |
129 | * @since 1.29 |
130 | */ |
131 | public function searchArchiveTitle( $term ) { |
132 | return $this->doSearchArchiveTitle( $term ); |
133 | } |
134 | |
135 | /** |
136 | * Perform a title search in the article archive. |
137 | * |
138 | * @stable to override |
139 | * |
140 | * @param string $term Raw search term |
141 | * @return Status |
142 | * @since 1.32 |
143 | */ |
144 | protected function doSearchArchiveTitle( $term ) { |
145 | return Status::newGood( [] ); |
146 | } |
147 | |
148 | /** |
149 | * Perform a title-only search query and return a result set. |
150 | * If title searches are not supported or disabled, return null. |
151 | * STUB |
152 | * |
153 | * @note As of 1.32 overriding this function is deprecated. It will |
154 | * be converted to final in 1.34. Override self::doSearchTitle(). |
155 | * |
156 | * @param string $term Raw search term |
157 | * @return ISearchResultSet|null |
158 | */ |
159 | public function searchTitle( $term ) { |
160 | return $this->maybePaginate( function () use ( $term ) { |
161 | return $this->doSearchTitle( $term ); |
162 | } ); |
163 | } |
164 | |
165 | /** |
166 | * Perform a title-only search query and return a result set. |
167 | * |
168 | * @stable to override |
169 | * |
170 | * @param string $term Raw search term |
171 | * @return ISearchResultSet|null |
172 | * @since 1.32 |
173 | */ |
174 | protected function doSearchTitle( $term ) { |
175 | return null; |
176 | } |
177 | |
178 | /** |
179 | * Performs an overfetch and shrink operation to determine if |
180 | * the next page is available for search engines that do not |
181 | * explicitly implement their own pagination. |
182 | * |
183 | * @param Closure $fn Takes no arguments |
184 | * @return ISearchResultSet|Status<ISearchResultSet>|null Result of calling $fn |
185 | */ |
186 | private function maybePaginate( Closure $fn ) { |
187 | if ( $this instanceof PaginatingSearchEngine ) { |
188 | return $fn(); |
189 | } |
190 | $this->limit++; |
191 | try { |
192 | $resultSetOrStatus = $fn(); |
193 | } finally { |
194 | $this->limit--; |
195 | } |
196 | |
197 | $resultSet = null; |
198 | if ( $resultSetOrStatus instanceof ISearchResultSet ) { |
199 | $resultSet = $resultSetOrStatus; |
200 | } elseif ( $resultSetOrStatus instanceof Status && |
201 | $resultSetOrStatus->getValue() instanceof ISearchResultSet |
202 | ) { |
203 | $resultSet = $resultSetOrStatus->getValue(); |
204 | } |
205 | if ( $resultSet ) { |
206 | $resultSet->shrink( $this->limit ); |
207 | } |
208 | |
209 | return $resultSetOrStatus; |
210 | } |
211 | |
212 | /** |
213 | * @since 1.18 |
214 | * @stable to override |
215 | * |
216 | * @param string $feature |
217 | * @return bool |
218 | */ |
219 | public function supports( $feature ) { |
220 | switch ( $feature ) { |
221 | case 'search-update': |
222 | return true; |
223 | case 'title-suffix-filter': |
224 | default: |
225 | return false; |
226 | } |
227 | } |
228 | |
229 | /** |
230 | * Way to pass custom data for engines |
231 | * @since 1.18 |
232 | * @param string $feature |
233 | * @param mixed $data |
234 | */ |
235 | public function setFeatureData( $feature, $data ) { |
236 | $this->features[$feature] = $data; |
237 | } |
238 | |
239 | /** |
240 | * Way to retrieve custom data set by setFeatureData |
241 | * or by the engine itself. |
242 | * @since 1.29 |
243 | * @param string $feature feature name |
244 | * @return mixed the feature value or null if unset |
245 | */ |
246 | public function getFeatureData( $feature ) { |
247 | return $this->features[$feature] ?? null; |
248 | } |
249 | |
250 | /** |
251 | * When overridden in derived class, performs database-specific conversions |
252 | * on text to be used for searching or updating search index. |
253 | * Default implementation does nothing (simply returns $string). |
254 | * |
255 | * @param string $string String to process |
256 | * @return string |
257 | */ |
258 | public function normalizeText( $string ) { |
259 | // Some languages such as Chinese require word segmentation |
260 | return MediaWikiServices::getInstance()->getContentLanguage()->segmentByWord( $string ); |
261 | } |
262 | |
263 | /** |
264 | * Get service class to finding near matches. |
265 | * |
266 | * @return TitleMatcher |
267 | * @deprecated since 1.40, use MediaWikiServices::getInstance()->getTitleMatcher() |
268 | */ |
269 | public function getNearMatcher( Config $config ) { |
270 | return MediaWikiServices::getInstance()->getTitleMatcher(); |
271 | } |
272 | |
273 | /** |
274 | * Get near matcher for default SearchEngine. |
275 | * |
276 | * @return TitleMatcher |
277 | * @deprecated since 1.40, MediaWikiServices::getInstance()->getTitleMatcher() |
278 | */ |
279 | protected static function defaultNearMatcher() { |
280 | wfDeprecated( __METHOD__, '1.40' ); |
281 | return MediaWikiServices::getInstance()->getTitleMatcher(); |
282 | } |
283 | |
284 | /** |
285 | * Get chars legal for search |
286 | * @param int $type type of search chars (see self::CHARS_ALL |
287 | * and self::CHARS_NO_SYNTAX). Defaults to CHARS_ALL |
288 | * @return string |
289 | */ |
290 | public function legalSearchChars( $type = self::CHARS_ALL ) { |
291 | return "A-Za-z_'.0-9\\x80-\\xFF\\-"; |
292 | } |
293 | |
294 | /** |
295 | * Set the maximum number of results to return |
296 | * and how many to skip before returning the first. |
297 | * |
298 | * @param int $limit |
299 | * @param int $offset |
300 | */ |
301 | public function setLimitOffset( $limit, $offset = 0 ) { |
302 | $this->limit = intval( $limit ); |
303 | $this->offset = intval( $offset ); |
304 | } |
305 | |
306 | /** |
307 | * Set which namespaces the search should include. |
308 | * Give an array of namespace index numbers. |
309 | * |
310 | * @param int[]|null $namespaces |
311 | */ |
312 | public function setNamespaces( $namespaces ) { |
313 | if ( $namespaces ) { |
314 | // Filter namespaces to only keep valid ones |
315 | $validNs = MediaWikiServices::getInstance()->getSearchEngineConfig()->searchableNamespaces(); |
316 | $namespaces = array_filter( $namespaces, static function ( $ns ) use( $validNs ) { |
317 | return $ns < 0 || isset( $validNs[$ns] ); |
318 | } ); |
319 | } else { |
320 | $namespaces = []; |
321 | } |
322 | $this->namespaces = $namespaces; |
323 | } |
324 | |
325 | /** |
326 | * Set whether the searcher should try to build a suggestion. Note: some searchers |
327 | * don't support building a suggestion in the first place and others don't respect |
328 | * this flag. |
329 | * |
330 | * @param bool $showSuggestion Should the searcher try to build suggestions |
331 | */ |
332 | public function setShowSuggestion( $showSuggestion ) { |
333 | $this->showSuggestion = $showSuggestion; |
334 | } |
335 | |
336 | /** |
337 | * Get the valid sort directions. All search engines support 'relevance' but others |
338 | * might support more. The default in all implementations must be 'relevance.' |
339 | * |
340 | * @since 1.25 |
341 | * @stable to override |
342 | * |
343 | * @return string[] the valid sort directions for setSort |
344 | */ |
345 | public function getValidSorts() { |
346 | return [ self::DEFAULT_SORT ]; |
347 | } |
348 | |
349 | /** |
350 | * Set the sort direction of the search results. Must be one returned by |
351 | * SearchEngine::getValidSorts() |
352 | * |
353 | * @since 1.25 |
354 | * @throws InvalidArgumentException |
355 | * @param string $sort sort direction for query result |
356 | */ |
357 | public function setSort( $sort ) { |
358 | if ( !in_array( $sort, $this->getValidSorts() ) ) { |
359 | throw new InvalidArgumentException( "Invalid sort: $sort. " . |
360 | "Must be one of: " . implode( ', ', $this->getValidSorts() ) ); |
361 | } |
362 | $this->sort = $sort; |
363 | } |
364 | |
365 | /** |
366 | * Get the sort direction of the search results |
367 | * |
368 | * @since 1.25 |
369 | * @return string |
370 | */ |
371 | public function getSort() { |
372 | return $this->sort; |
373 | } |
374 | |
375 | /** |
376 | * Parse some common prefixes: all (search everything) |
377 | * or namespace names and set the list of namespaces |
378 | * of this class accordingly. |
379 | * |
380 | * @deprecated since 1.32; should be handled internally by the search engine |
381 | * @param string $query |
382 | * @return string |
383 | */ |
384 | public function replacePrefixes( $query ) { |
385 | return $query; |
386 | } |
387 | |
388 | /** |
389 | * Parse some common prefixes: all (search everything) |
390 | * or namespace names |
391 | * |
392 | * @param string $query |
393 | * @param bool $withAllKeyword activate support of the "all:" keyword and its |
394 | * translations to activate searching on all namespaces. |
395 | * @param bool $withPrefixSearchExtractNamespaceHook call the PrefixSearchExtractNamespace hook |
396 | * if classic namespace identification did not match. |
397 | * @return false|array false if no namespace was extracted, an array |
398 | * with the parsed query at index 0 and an array of namespaces at index |
399 | * 1 (or null for all namespaces). |
400 | */ |
401 | public static function parseNamespacePrefixes( |
402 | $query, |
403 | $withAllKeyword = true, |
404 | $withPrefixSearchExtractNamespaceHook = false |
405 | ) { |
406 | $parsed = $query; |
407 | if ( strpos( $query, ':' ) === false ) { // nothing to do |
408 | return false; |
409 | } |
410 | $extractedNamespace = null; |
411 | |
412 | $allQuery = false; |
413 | if ( $withAllKeyword ) { |
414 | $allkeywords = []; |
415 | |
416 | $allkeywords[] = wfMessage( 'searchall' )->inContentLanguage()->text() . ":"; |
417 | // force all: so that we have a common syntax for all the wikis |
418 | if ( !in_array( 'all:', $allkeywords ) ) { |
419 | $allkeywords[] = 'all:'; |
420 | } |
421 | |
422 | foreach ( $allkeywords as $kw ) { |
423 | if ( str_starts_with( $query, $kw ) ) { |
424 | $parsed = substr( $query, strlen( $kw ) ); |
425 | $allQuery = true; |
426 | break; |
427 | } |
428 | } |
429 | } |
430 | |
431 | if ( !$allQuery && strpos( $query, ':' ) !== false ) { |
432 | $prefix = str_replace( ' ', '_', substr( $query, 0, strpos( $query, ':' ) ) ); |
433 | $services = MediaWikiServices::getInstance(); |
434 | $index = $services->getContentLanguage()->getNsIndex( $prefix ); |
435 | if ( $index !== false ) { |
436 | $extractedNamespace = [ $index ]; |
437 | $parsed = substr( $query, strlen( $prefix ) + 1 ); |
438 | } elseif ( $withPrefixSearchExtractNamespaceHook ) { |
439 | $hookNamespaces = [ NS_MAIN ]; |
440 | $hookQuery = $query; |
441 | ( new HookRunner( $services->getHookContainer() ) ) |
442 | ->onPrefixSearchExtractNamespace( $hookNamespaces, $hookQuery ); |
443 | if ( $hookQuery !== $query ) { |
444 | $parsed = $hookQuery; |
445 | $extractedNamespace = $hookNamespaces; |
446 | } else { |
447 | return false; |
448 | } |
449 | } else { |
450 | return false; |
451 | } |
452 | } |
453 | |
454 | return [ $parsed, $extractedNamespace ]; |
455 | } |
456 | |
457 | /** |
458 | * Find snippet highlight settings for all users |
459 | * @return array Contextlines, contextchars |
460 | * @deprecated in 1.34 use the SearchHighlighter constants directly |
461 | * @see SearchHighlighter::DEFAULT_CONTEXT_CHARS |
462 | * @see SearchHighlighter::DEFAULT_CONTEXT_LINES |
463 | */ |
464 | public static function userHighlightPrefs() { |
465 | $contextlines = SearchHighlighter::DEFAULT_CONTEXT_LINES; |
466 | $contextchars = SearchHighlighter::DEFAULT_CONTEXT_CHARS; |
467 | return [ $contextlines, $contextchars ]; |
468 | } |
469 | |
470 | /** |
471 | * Create or update the search index record for the given page. |
472 | * Title and text should be pre-processed. |
473 | * STUB |
474 | * |
475 | * @param int $id |
476 | * @param string $title |
477 | * @param string $text |
478 | */ |
479 | public function update( $id, $title, $text ) { |
480 | // no-op |
481 | } |
482 | |
483 | /** |
484 | * Update a search index record's title only. |
485 | * Title should be pre-processed. |
486 | * STUB |
487 | * |
488 | * @param int $id |
489 | * @param string $title |
490 | */ |
491 | public function updateTitle( $id, $title ) { |
492 | // no-op |
493 | } |
494 | |
495 | /** |
496 | * Delete an indexed page |
497 | * Title should be pre-processed. |
498 | * STUB |
499 | * |
500 | * @param int $id Page id that was deleted |
501 | * @param string $title Title of page that was deleted |
502 | */ |
503 | public function delete( $id, $title ) { |
504 | // no-op |
505 | } |
506 | |
507 | /** |
508 | * Get the raw text for updating the index from a content object |
509 | * Nicer search backends could possibly do something cooler than |
510 | * just returning raw text |
511 | * |
512 | * @todo This isn't ideal, we'd really like to have content-specific handling here |
513 | * @param Title $t Title we're indexing |
514 | * @param Content|null $c Content of the page to index |
515 | * @return string |
516 | * @deprecated since 1.34 use Content::getTextForSearchIndex directly |
517 | */ |
518 | public function getTextFromContent( Title $t, Content $c = null ) { |
519 | return $c ? $c->getTextForSearchIndex() : ''; |
520 | } |
521 | |
522 | /** |
523 | * If an implementation of SearchEngine handles all of its own text processing |
524 | * in getTextFromContent() and doesn't require SearchUpdate::updateText()'s |
525 | * rather silly handling, it should return true here instead. |
526 | * |
527 | * @return bool |
528 | * @deprecated since 1.34 no longer needed since getTextFromContent is being deprecated |
529 | */ |
530 | public function textAlreadyUpdatedForIndex() { |
531 | return false; |
532 | } |
533 | |
534 | /** |
535 | * Makes search simple string if it was namespaced. |
536 | * Sets namespaces of the search to namespaces extracted from string. |
537 | * @param string $search |
538 | * @return string Simplified search string |
539 | */ |
540 | protected function normalizeNamespaces( $search ) { |
541 | $queryAndNs = self::parseNamespacePrefixes( $search, false, true ); |
542 | if ( $queryAndNs !== false ) { |
543 | $this->setNamespaces( $queryAndNs[1] ); |
544 | return $queryAndNs[0]; |
545 | } |
546 | return $search; |
547 | } |
548 | |
549 | /** |
550 | * Perform an overfetch of completion search results. This allows |
551 | * determining if another page of results is available. |
552 | * |
553 | * @param string $search |
554 | * @return SearchSuggestionSet |
555 | */ |
556 | protected function completionSearchBackendOverfetch( $search ) { |
557 | $this->limit++; |
558 | try { |
559 | return $this->completionSearchBackend( $search ); |
560 | } finally { |
561 | $this->limit--; |
562 | } |
563 | } |
564 | |
565 | /** |
566 | * Perform a completion search. |
567 | * Does not resolve namespaces and does not check variants. |
568 | * Search engine implementations may want to override this function. |
569 | * |
570 | * @stable to override |
571 | * |
572 | * @param string $search |
573 | * @return SearchSuggestionSet |
574 | */ |
575 | protected function completionSearchBackend( $search ) { |
576 | $results = []; |
577 | |
578 | $search = trim( $search ); |
579 | |
580 | if ( !in_array( NS_SPECIAL, $this->namespaces ) && // We do not run hook on Special: search |
581 | !$this->getHookRunner()->onPrefixSearchBackend( |
582 | $this->namespaces, $search, $this->limit, $results, $this->offset ) |
583 | ) { |
584 | // False means hook worked. |
585 | // FIXME: Yes, the API is weird. That's why it is going to be deprecated. |
586 | |
587 | return SearchSuggestionSet::fromStrings( $results ); |
588 | } else { |
589 | // Hook did not do the job, use default simple search |
590 | $results = $this->simplePrefixSearch( $search ); |
591 | return SearchSuggestionSet::fromTitles( $results ); |
592 | } |
593 | } |
594 | |
595 | /** |
596 | * Perform a completion search. |
597 | * @param string $search |
598 | * @return SearchSuggestionSet |
599 | */ |
600 | public function completionSearch( $search ) { |
601 | if ( trim( $search ) === '' ) { |
602 | return SearchSuggestionSet::emptySuggestionSet(); // Return empty result |
603 | } |
604 | $search = $this->normalizeNamespaces( $search ); |
605 | $suggestions = $this->completionSearchBackendOverfetch( $search ); |
606 | return $this->processCompletionResults( $search, $suggestions ); |
607 | } |
608 | |
609 | /** |
610 | * Perform a completion search with variants. |
611 | * @stable to override |
612 | * |
613 | * @param string $search |
614 | * @return SearchSuggestionSet |
615 | */ |
616 | public function completionSearchWithVariants( $search ) { |
617 | if ( trim( $search ) === '' ) { |
618 | return SearchSuggestionSet::emptySuggestionSet(); // Return empty result |
619 | } |
620 | $search = $this->normalizeNamespaces( $search ); |
621 | |
622 | $results = $this->completionSearchBackendOverfetch( $search ); |
623 | $fallbackLimit = 1 + $this->limit - $results->getSize(); |
624 | if ( $fallbackLimit > 0 ) { |
625 | $services = MediaWikiServices::getInstance(); |
626 | $fallbackSearches = $services->getLanguageConverterFactory() |
627 | ->getLanguageConverter( $services->getContentLanguage() ) |
628 | ->autoConvertToAllVariants( $search ); |
629 | $fallbackSearches = array_diff( array_unique( $fallbackSearches ), [ $search ] ); |
630 | |
631 | foreach ( $fallbackSearches as $fbs ) { |
632 | $this->setLimitOffset( $fallbackLimit ); |
633 | $fallbackSearchResult = $this->completionSearch( $fbs ); |
634 | $results->appendAll( $fallbackSearchResult ); |
635 | $fallbackLimit -= $fallbackSearchResult->getSize(); |
636 | if ( $fallbackLimit <= 0 ) { |
637 | break; |
638 | } |
639 | } |
640 | } |
641 | return $this->processCompletionResults( $search, $results ); |
642 | } |
643 | |
644 | /** |
645 | * Extract titles from completion results |
646 | * @param SearchSuggestionSet $completionResults |
647 | * @return Title[] |
648 | */ |
649 | public function extractTitles( SearchSuggestionSet $completionResults ) { |
650 | return $completionResults->map( static function ( SearchSuggestion $sugg ) { |
651 | return $sugg->getSuggestedTitle(); |
652 | } ); |
653 | } |
654 | |
655 | /** |
656 | * Process completion search results. |
657 | * Resolves the titles and rescores. |
658 | * @param string $search |
659 | * @param SearchSuggestionSet $suggestions |
660 | * @return SearchSuggestionSet |
661 | */ |
662 | protected function processCompletionResults( $search, SearchSuggestionSet $suggestions ) { |
663 | // We over-fetched to determine pagination. Shrink back down if we have extra results |
664 | // and mark if pagination is possible |
665 | $suggestions->shrink( $this->limit ); |
666 | |
667 | $search = trim( $search ); |
668 | // preload the titles with LinkBatch |
669 | $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory(); |
670 | $lb = $linkBatchFactory->newLinkBatch( $suggestions->map( static function ( SearchSuggestion $sugg ) { |
671 | return $sugg->getSuggestedTitle(); |
672 | } ) ); |
673 | $lb->setCaller( __METHOD__ ); |
674 | $lb->execute(); |
675 | |
676 | $diff = $suggestions->filter( static function ( SearchSuggestion $sugg ) { |
677 | return $sugg->getSuggestedTitle()->isKnown(); |
678 | } ); |
679 | if ( $diff > 0 ) { |
680 | MediaWikiServices::getInstance()->getStatsdDataFactory() |
681 | ->updateCount( 'search.completion.missing', $diff ); |
682 | } |
683 | |
684 | // SearchExactMatchRescorer should probably be refactored to work directly on top of a SearchSuggestionSet |
685 | // instead of converting it to array and trying to infer if it has re-scored anything by inspected the head |
686 | // of the returned array. |
687 | $results = $suggestions->map( static function ( SearchSuggestion $sugg ) { |
688 | return $sugg->getSuggestedTitle()->getPrefixedText(); |
689 | } ); |
690 | |
691 | $rescorer = new SearchExactMatchRescorer(); |
692 | if ( $this->offset === 0 ) { |
693 | // Rescore results with an exact title match |
694 | // NOTE: in some cases like cross-namespace redirects |
695 | // (frequently used as shortcuts e.g. WP:WP on huwiki) some |
696 | // backends like Cirrus will return no results. We should still |
697 | // try an exact title match to workaround this limitation |
698 | $rescoredResults = $rescorer->rescore( $search, $this->namespaces, $results, $this->limit ); |
699 | } else { |
700 | // No need to rescore if offset is not 0 |
701 | // The exact match must have been returned at position 0 |
702 | // if it existed. |
703 | $rescoredResults = $results; |
704 | } |
705 | |
706 | if ( count( $rescoredResults ) > 0 ) { |
707 | $found = array_search( $rescoredResults[0], $results ); |
708 | if ( $found === false ) { |
709 | // If the first result is not in the previous array it |
710 | // means that we found a new exact match |
711 | $exactMatch = SearchSuggestion::fromTitle( 0, Title::newFromText( $rescoredResults[0] ) ); |
712 | $suggestions->prepend( $exactMatch ); |
713 | if ( $rescorer->getReplacedRedirect() !== null ) { |
714 | // the exact match rescorer replaced one of the suggestion found by the search engine |
715 | // let's remove it from our suggestions set to avoid showing duplicates |
716 | $suggestions->remove( SearchSuggestion::fromTitle( 0, |
717 | Title::newFromText( $rescorer->getReplacedRedirect() ) ) ); |
718 | } |
719 | $suggestions->shrink( $this->limit ); |
720 | } else { |
721 | // if the first result is not the same we need to rescore |
722 | if ( $found > 0 ) { |
723 | $suggestions->rescore( $found ); |
724 | } |
725 | } |
726 | } |
727 | |
728 | return $suggestions; |
729 | } |
730 | |
731 | /** |
732 | * Simple prefix search for subpages. |
733 | * @param string $search |
734 | * @return Title[] |
735 | */ |
736 | public function defaultPrefixSearch( $search ) { |
737 | if ( trim( $search ) === '' ) { |
738 | return []; |
739 | } |
740 | |
741 | $search = $this->normalizeNamespaces( $search ); |
742 | return $this->simplePrefixSearch( $search ); |
743 | } |
744 | |
745 | /** |
746 | * Call out to simple search backend. |
747 | * Defaults to TitlePrefixSearch. |
748 | * @param string $search |
749 | * @return Title[] |
750 | */ |
751 | protected function simplePrefixSearch( $search ) { |
752 | // Use default database prefix search |
753 | $backend = new TitlePrefixSearch; |
754 | return $backend->defaultSearchBackend( $this->namespaces, $search, $this->limit, $this->offset ); |
755 | } |
756 | |
757 | /** |
758 | * Get a list of supported profiles. |
759 | * Some search engine implementations may expose specific profiles to fine-tune |
760 | * its behaviors. |
761 | * The profile can be passed as a feature data with setFeatureData( $profileType, $profileName ) |
762 | * The array returned by this function contains the following keys: |
763 | * - name: the profile name to use with setFeatureData |
764 | * - desc-message: the i18n description |
765 | * - default: set to true if this profile is the default |
766 | * |
767 | * @since 1.28 |
768 | * @stable to override |
769 | * |
770 | * @param string $profileType the type of profiles |
771 | * @param User|null $user the user requesting the list of profiles |
772 | * @return array|null the list of profiles or null if none available |
773 | * @phan-return null|array{name:string,desc-message:string,default?:bool} |
774 | */ |
775 | public function getProfiles( $profileType, User $user = null ) { |
776 | return null; |
777 | } |
778 | |
779 | /** |
780 | * Create a search field definition. |
781 | * Specific search engines should override this method to create search fields. |
782 | * @stable to override |
783 | * |
784 | * @param string $name |
785 | * @param string $type One of the types in SearchIndexField::INDEX_TYPE_* |
786 | * @return SearchIndexField |
787 | * @since 1.28 |
788 | */ |
789 | public function makeSearchFieldMapping( $name, $type ) { |
790 | return new NullIndexField(); |
791 | } |
792 | |
793 | /** |
794 | * Get fields for search index |
795 | * @since 1.28 |
796 | * @return SearchIndexField[] Index field definitions for all content handlers |
797 | */ |
798 | public function getSearchIndexFields() { |
799 | $models = MediaWikiServices::getInstance()->getContentHandlerFactory()->getContentModels(); |
800 | $fields = []; |
801 | $seenHandlers = new SplObjectStorage(); |
802 | foreach ( $models as $model ) { |
803 | try { |
804 | $handler = MediaWikiServices::getInstance() |
805 | ->getContentHandlerFactory() |
806 | ->getContentHandler( $model ); |
807 | } catch ( MWUnknownContentModelException $e ) { |
808 | // If we can find no handler, ignore it |
809 | continue; |
810 | } |
811 | // Several models can have the same handler, so avoid processing it repeatedly |
812 | if ( $seenHandlers->contains( $handler ) ) { |
813 | // We already did this one |
814 | continue; |
815 | } |
816 | $seenHandlers->attach( $handler ); |
817 | $handlerFields = $handler->getFieldsForSearchIndex( $this ); |
818 | foreach ( $handlerFields as $fieldName => $fieldData ) { |
819 | if ( empty( $fields[$fieldName] ) ) { |
820 | $fields[$fieldName] = $fieldData; |
821 | } else { |
822 | // TODO: do we allow some clashes with the same type or reject all of them? |
823 | $mergeDef = $fields[$fieldName]->merge( $fieldData ); |
824 | if ( !$mergeDef ) { |
825 | throw new InvalidArgumentException( "Duplicate field $fieldName for model $model" ); |
826 | } |
827 | $fields[$fieldName] = $mergeDef; |
828 | } |
829 | } |
830 | } |
831 | // Hook to allow extensions to produce search mapping fields |
832 | $this->getHookRunner()->onSearchIndexFields( $fields, $this ); |
833 | return $fields; |
834 | } |
835 | |
836 | /** |
837 | * Augment search results with extra data. |
838 | * |
839 | * @param ISearchResultSet $resultSet |
840 | */ |
841 | public function augmentSearchResults( ISearchResultSet $resultSet ) { |
842 | $setAugmentors = []; |
843 | $rowAugmentors = []; |
844 | $this->getHookRunner()->onSearchResultsAugment( $setAugmentors, $rowAugmentors ); |
845 | if ( !$setAugmentors && !$rowAugmentors ) { |
846 | // We're done here |
847 | return; |
848 | } |
849 | |
850 | // Convert row augmentors to set augmentor |
851 | foreach ( $rowAugmentors as $name => $row ) { |
852 | if ( isset( $setAugmentors[$name] ) ) { |
853 | throw new InvalidArgumentException( "Both row and set augmentors are defined for $name" ); |
854 | } |
855 | $setAugmentors[$name] = new PerRowAugmentor( $row ); |
856 | } |
857 | |
858 | /** |
859 | * @var string $name |
860 | * @var ResultSetAugmentor $augmentor |
861 | */ |
862 | foreach ( $setAugmentors as $name => $augmentor ) { |
863 | $data = $augmentor->augmentAll( $resultSet ); |
864 | if ( $data ) { |
865 | $resultSet->setAugmentedData( $name, $data ); |
866 | } |
867 | } |
868 | } |
869 | |
870 | /** |
871 | * @since 1.35 |
872 | * @internal |
873 | * @param HookContainer $hookContainer |
874 | */ |
875 | public function setHookContainer( HookContainer $hookContainer ) { |
876 | $this->hookContainer = $hookContainer; |
877 | $this->hookRunner = new HookRunner( $hookContainer ); |
878 | } |
879 | |
880 | /** |
881 | * Get a HookContainer, for running extension hooks or for hook metadata. |
882 | * |
883 | * @since 1.35 |
884 | * @return HookContainer |
885 | */ |
886 | protected function getHookContainer(): HookContainer { |
887 | if ( !$this->hookContainer ) { |
888 | // This shouldn't be hit in core, but it is needed for CirrusSearch |
889 | // which commonly creates a CirrusSearch object without cirrus being |
890 | // configured in $wgSearchType/$wgSearchTypeAlternatives. |
891 | $this->hookContainer = MediaWikiServices::getInstance()->getHookContainer(); |
892 | } |
893 | return $this->hookContainer; |
894 | } |
895 | |
896 | /** |
897 | * Get a HookRunner for running core hooks. |
898 | * |
899 | * @internal This is for use by core only. Hook interfaces may be removed |
900 | * without notice. |
901 | * @since 1.35 |
902 | * @return HookRunner |
903 | */ |
904 | protected function getHookRunner(): HookRunner { |
905 | if ( !$this->hookRunner ) { |
906 | $this->hookRunner = new HookRunner( $this->getHookContainer() ); |
907 | } |
908 | return $this->hookRunner; |
909 | } |
910 | |
911 | } |