Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.14% |
68 / 70 |
|
75.00% |
3 / 4 |
CRAP | |
0.00% |
0 / 1 |
TitleMatcher | |
97.14% |
68 / 70 |
|
75.00% |
3 / 4 |
35 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
getNearMatch | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getNearMatchResultSet | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getNearMatchInternal | |
96.49% |
55 / 57 |
|
0.00% |
0 / 1 |
32 |
1 | <?php |
2 | namespace MediaWiki\Search; |
3 | |
4 | use ILanguageConverter; |
5 | use ISearchResultSet; |
6 | use Language; |
7 | use MediaWiki\Config\ServiceOptions; |
8 | use MediaWiki\HookContainer\HookContainer; |
9 | use MediaWiki\HookContainer\HookRunner; |
10 | use MediaWiki\Languages\LanguageConverterFactory; |
11 | use MediaWiki\MainConfigNames; |
12 | use MediaWiki\Page\WikiPageFactory; |
13 | use MediaWiki\SpecialPage\SpecialPage; |
14 | use MediaWiki\Title\Title; |
15 | use MediaWiki\Title\TitleFactory; |
16 | use MediaWiki\User\UserNameUtils; |
17 | use RepoGroup; |
18 | use SearchNearMatchResultSet; |
19 | |
20 | /** |
21 | * Service implementation of near match title search. |
22 | */ |
23 | class TitleMatcher { |
24 | /** |
25 | * @internal For use by ServiceWiring. |
26 | * @var string[] |
27 | */ |
28 | public const CONSTRUCTOR_OPTIONS = [ |
29 | MainConfigNames::EnableSearchContributorsByIP, |
30 | ]; |
31 | |
32 | /** |
33 | * @var ServiceOptions |
34 | */ |
35 | private $options; |
36 | |
37 | /** |
38 | * Current language |
39 | * @var Language |
40 | */ |
41 | private $language; |
42 | |
43 | /** |
44 | * Current language converter |
45 | * @var ILanguageConverter |
46 | */ |
47 | private $languageConverter; |
48 | |
49 | /** |
50 | * @var HookRunner |
51 | */ |
52 | private $hookRunner; |
53 | |
54 | /** |
55 | * @var WikiPageFactory |
56 | */ |
57 | private $wikiPageFactory; |
58 | |
59 | /** |
60 | * @var UserNameUtils |
61 | */ |
62 | private $userNameUtils; |
63 | |
64 | /** |
65 | * @var RepoGroup |
66 | */ |
67 | private $repoGroup; |
68 | |
69 | private TitleFactory $titleFactory; |
70 | |
71 | /** |
72 | * @param ServiceOptions $options |
73 | * @param Language $contentLanguage |
74 | * @param LanguageConverterFactory $languageConverterFactory |
75 | * @param HookContainer $hookContainer |
76 | * @param WikiPageFactory $wikiPageFactory |
77 | * @param UserNameUtils $userNameUtils |
78 | * @param RepoGroup $repoGroup |
79 | * @param TitleFactory $titleFactory |
80 | */ |
81 | public function __construct( |
82 | ServiceOptions $options, |
83 | Language $contentLanguage, |
84 | LanguageConverterFactory $languageConverterFactory, |
85 | HookContainer $hookContainer, |
86 | WikiPageFactory $wikiPageFactory, |
87 | UserNameUtils $userNameUtils, |
88 | RepoGroup $repoGroup, |
89 | TitleFactory $titleFactory |
90 | ) { |
91 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
92 | $this->options = $options; |
93 | |
94 | $this->language = $contentLanguage; |
95 | $this->languageConverter = $languageConverterFactory->getLanguageConverter( $contentLanguage ); |
96 | $this->hookRunner = new HookRunner( $hookContainer ); |
97 | $this->wikiPageFactory = $wikiPageFactory; |
98 | $this->userNameUtils = $userNameUtils; |
99 | $this->repoGroup = $repoGroup; |
100 | $this->titleFactory = $titleFactory; |
101 | } |
102 | |
103 | /** |
104 | * If an exact title match can be found, or a very slightly close match, |
105 | * return the title. If no match, returns NULL. |
106 | * |
107 | * @param string $searchterm |
108 | * @return Title |
109 | */ |
110 | public function getNearMatch( $searchterm ) { |
111 | $title = $this->getNearMatchInternal( $searchterm ); |
112 | |
113 | $this->hookRunner->onSearchGetNearMatchComplete( $searchterm, $title ); |
114 | return $title; |
115 | } |
116 | |
117 | /** |
118 | * Do a near match (see SearchEngine::getNearMatch) and wrap it into a |
119 | * ISearchResultSet. |
120 | * |
121 | * @param string $searchterm |
122 | * @return ISearchResultSet |
123 | */ |
124 | public function getNearMatchResultSet( $searchterm ) { |
125 | return new SearchNearMatchResultSet( $this->getNearMatch( $searchterm ) ); |
126 | } |
127 | |
128 | /** |
129 | * Really find the title match. |
130 | * @param string $searchterm |
131 | * @return null|Title |
132 | */ |
133 | protected function getNearMatchInternal( $searchterm ) { |
134 | $allSearchTerms = [ $searchterm ]; |
135 | |
136 | if ( $this->languageConverter->hasVariants() ) { |
137 | $allSearchTerms = array_unique( array_merge( |
138 | $allSearchTerms, |
139 | $this->languageConverter->autoConvertToAllVariants( $searchterm ) |
140 | ) ); |
141 | } |
142 | |
143 | $titleResult = null; |
144 | if ( !$this->hookRunner->onSearchGetNearMatchBefore( $allSearchTerms, $titleResult ) ) { |
145 | return $titleResult; |
146 | } |
147 | |
148 | // Most of our handling here deals with finding a valid title for the search term, |
149 | // but almost anything starting with '#' is "valid" and points to Main_Page#searchterm. |
150 | // Rather than doing something completely wrong, do nothing. |
151 | if ( $searchterm === '' || $searchterm[0] === '#' ) { |
152 | return null; |
153 | } |
154 | |
155 | foreach ( $allSearchTerms as $term ) { |
156 | # Exact match? No need to look further. |
157 | $title = $this->titleFactory->newFromText( $term ); |
158 | if ( $title === null ) { |
159 | return null; |
160 | } |
161 | |
162 | # Try files if searching in the Media: namespace |
163 | if ( $title->getNamespace() === NS_MEDIA ) { |
164 | $title = Title::makeTitle( NS_FILE, $title->getText() ); |
165 | } |
166 | |
167 | if ( $title->isSpecialPage() || $title->isExternal() || $title->exists() ) { |
168 | return $title; |
169 | } |
170 | |
171 | # See if it still otherwise has content is some sensible sense |
172 | if ( $title->canExist() ) { |
173 | $page = $this->wikiPageFactory->newFromTitle( $title ); |
174 | if ( $page->hasViewableContent() ) { |
175 | return $title; |
176 | } |
177 | } |
178 | |
179 | if ( !$this->hookRunner->onSearchAfterNoDirectMatch( $term, $title ) ) { |
180 | return $title; |
181 | } |
182 | |
183 | # Now try all lower case (i.e. first letter capitalized) |
184 | $title = $this->titleFactory->newFromText( $this->language->lc( $term ) ); |
185 | if ( $title && $title->exists() ) { |
186 | return $title; |
187 | } |
188 | |
189 | # Now try capitalized string |
190 | $title = $this->titleFactory->newFromText( $this->language->ucwords( $term ) ); |
191 | if ( $title && $title->exists() ) { |
192 | return $title; |
193 | } |
194 | |
195 | # Now try all upper case |
196 | $title = $this->titleFactory->newFromText( $this->language->uc( $term ) ); |
197 | if ( $title && $title->exists() ) { |
198 | return $title; |
199 | } |
200 | |
201 | # Now try Word-Caps-Breaking-At-Word-Breaks, for hyphenated names etc |
202 | $title = $this->titleFactory->newFromText( $this->language->ucwordbreaks( $term ) ); |
203 | if ( $title && $title->exists() ) { |
204 | return $title; |
205 | } |
206 | |
207 | // Give hooks a chance at better match variants |
208 | $title = null; |
209 | // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args |
210 | if ( !$this->hookRunner->onSearchGetNearMatch( $term, $title ) ) { |
211 | return $title; |
212 | } |
213 | } |
214 | |
215 | $title = $this->titleFactory->newFromTextThrow( $searchterm ); |
216 | |
217 | # Entering an IP address goes to the contributions page |
218 | if ( $this->options->get( MainConfigNames::EnableSearchContributorsByIP ) ) { |
219 | if ( ( $title->getNamespace() === NS_USER && $this->userNameUtils->isIP( $title->getText() ) ) |
220 | || $this->userNameUtils->isIP( trim( $searchterm ) ) ) { |
221 | return SpecialPage::getTitleFor( 'Contributions', $title->getDBkey() ); |
222 | } |
223 | } |
224 | |
225 | # Entering a user goes to the user page whether it's there or not |
226 | if ( $title->getNamespace() === NS_USER ) { |
227 | return $title; |
228 | } |
229 | |
230 | # Go to images that exist even if there's no local page. |
231 | # There may have been a funny upload, or it may be on a shared |
232 | # file repository such as Wikimedia Commons. |
233 | if ( $title->getNamespace() === NS_FILE ) { |
234 | $image = $this->repoGroup->findFile( $title ); |
235 | if ( $image ) { |
236 | return $title; |
237 | } |
238 | } |
239 | |
240 | # MediaWiki namespace? Page may be "implied" if not customized. |
241 | # Just return it, with caps forced as the message system likes it. |
242 | if ( $title->getNamespace() === NS_MEDIAWIKI ) { |
243 | return Title::makeTitle( NS_MEDIAWIKI, $this->language->ucfirst( $title->getText() ) ); |
244 | } |
245 | |
246 | # Quoted term? Try without the quotes... |
247 | $matches = []; |
248 | if ( preg_match( '/^"([^"]+)"$/', $searchterm, $matches ) ) { |
249 | return $this->getNearMatch( $matches[1] ); |
250 | } |
251 | |
252 | return null; |
253 | } |
254 | } |