Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.39% |
183 / 186 |
|
75.00% |
9 / 12 |
CRAP | |
0.00% |
0 / 1 |
SpecialLintErrors | |
98.39% |
183 / 186 |
|
75.00% |
9 / 12 |
33 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
showFilterForm | |
100.00% |
59 / 59 |
|
100.00% |
1 / 1 |
3 | |||
cleanTitle | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
8 | |||
displayError | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
findNamespaces | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
execute | |
98.48% |
65 / 66 |
|
0.00% |
0 / 1 |
11 | |||
displayList | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
displaySearchPage | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
showCategoryListings | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
buildCategoryList | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getSubpagesForPrefixSearch | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\Linter; |
22 | |
23 | use HTMLForm; |
24 | use MediaWiki\Cache\LinkCache; |
25 | use MediaWiki\Html\Html; |
26 | use MediaWiki\Output\OutputPage; |
27 | use MediaWiki\Permissions\PermissionManager; |
28 | use MediaWiki\Request\WebRequest; |
29 | use MediaWiki\SpecialPage\SpecialPage; |
30 | use MediaWiki\Title\MalformedTitleException; |
31 | use MediaWiki\Title\NamespaceInfo; |
32 | use MediaWiki\Title\TitleParser; |
33 | |
34 | class SpecialLintErrors extends SpecialPage { |
35 | |
36 | private NamespaceInfo $namespaceInfo; |
37 | private TitleParser $titleParser; |
38 | private LinkCache $linkCache; |
39 | private PermissionManager $permissionManager; |
40 | private CategoryManager $categoryManager; |
41 | private TotalsLookup $totalsLookup; |
42 | |
43 | /** |
44 | * @var string|null |
45 | */ |
46 | private $category; |
47 | |
48 | /** |
49 | * @param NamespaceInfo $namespaceInfo |
50 | * @param TitleParser $titleParser |
51 | * @param LinkCache $linkCache |
52 | * @param PermissionManager $permissionManager |
53 | * @param CategoryManager $categoryManager |
54 | * @param TotalsLookup $totalsLookup |
55 | */ |
56 | public function __construct( |
57 | NamespaceInfo $namespaceInfo, |
58 | TitleParser $titleParser, |
59 | LinkCache $linkCache, |
60 | PermissionManager $permissionManager, |
61 | CategoryManager $categoryManager, |
62 | TotalsLookup $totalsLookup |
63 | ) { |
64 | parent::__construct( 'LintErrors' ); |
65 | $this->namespaceInfo = $namespaceInfo; |
66 | $this->titleParser = $titleParser; |
67 | $this->linkCache = $linkCache; |
68 | $this->permissionManager = $permissionManager; |
69 | $this->categoryManager = $categoryManager; |
70 | $this->totalsLookup = $totalsLookup; |
71 | } |
72 | |
73 | /** |
74 | * @param string $titleLabel |
75 | */ |
76 | protected function showFilterForm( $titleLabel ) { |
77 | $selectOptions = [ |
78 | (string)$this->msg( 'linter-form-exact-match' )->escaped() => true, |
79 | (string)$this->msg( 'linter-form-prefix-match' )->escaped() => false, |
80 | ]; |
81 | $namespaces = $this->getContext()->getRequest()->getVal( "wpNamespaceRestrictions" ); |
82 | $fields = [ |
83 | 'NamespaceRestrictions' => [ |
84 | 'type' => 'namespacesmultiselect', |
85 | 'label' => $this->msg( 'linter-form-namespace' )->text(), |
86 | 'exists' => true, |
87 | 'cssclass' => 'mw-block-partial-restriction', |
88 | 'default' => $namespaces, |
89 | 'input' => [ 'autocomplete' => false ] |
90 | ], |
91 | 'titlefield' => [ |
92 | 'type' => 'title', |
93 | 'name' => $titleLabel, |
94 | 'label-message' => 'linter-form-title-prefix', |
95 | 'exists' => true, |
96 | 'required' => false |
97 | ], |
98 | 'exactmatchradio' => [ |
99 | 'type' => 'radio', |
100 | 'name' => 'exactmatch', |
101 | 'options' => $selectOptions, |
102 | 'label-message' => 'linter-form-exact-or-prefix', |
103 | 'default' => true |
104 | ] |
105 | ]; |
106 | |
107 | $enableUserInterfaceTagAndTemplateStage = |
108 | $this->getConfig()->get( 'LinterUserInterfaceTagAndTemplateStage' ); |
109 | if ( $enableUserInterfaceTagAndTemplateStage ) { |
110 | $selectTemplateOptions = [ |
111 | (string)$this->msg( 'linter-form-template-option-all' )->escaped() => 'all', |
112 | (string)$this->msg( 'linter-form-template-option-with' )->escaped() => 'with', |
113 | (string)$this->msg( 'linter-form-template-option-without' )->escaped() => 'without', |
114 | ]; |
115 | $htmlTags = new HtmlTags( $this ); |
116 | $tagAndTemplateFields = [ |
117 | 'tag' => [ |
118 | 'type' => 'select', |
119 | 'name' => 'tag', |
120 | 'label-message' => 'linter-form-tag', |
121 | 'options' => $htmlTags->getAllowedHTMLTags() |
122 | ], |
123 | 'template' => [ |
124 | 'type' => 'select', |
125 | 'name' => 'template', |
126 | 'label-message' => 'linter-form-template', |
127 | 'options' => $selectTemplateOptions |
128 | ] |
129 | ]; |
130 | $fields = array_merge( $fields, $tagAndTemplateFields ); |
131 | } |
132 | |
133 | $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() ); |
134 | $form->setWrapperLegend( true ); |
135 | if ( $this->category !== null ) { |
136 | $form->addHeaderHtml( $this->msg( "linter-category-{$this->category}-desc" )->parse() ); |
137 | } |
138 | $form->setMethod( 'get' ); |
139 | $form->prepareForm()->displayForm( false ); |
140 | } |
141 | |
142 | /** |
143 | * cleanTitle parses a title and handles a malformed titles, namespaces that are mismatched |
144 | * and exact title searches that find no matching records, and produce appropriate error messages |
145 | * |
146 | * @param string $title |
147 | * @param array $namespaces |
148 | * @return array |
149 | */ |
150 | public function cleanTitle( string $title, $namespaces ): array { |
151 | // Check all titles for malformation regardless of exact match or prefix match |
152 | try { |
153 | $titleElements = $this->titleParser->parseTitle( $title ); |
154 | } catch ( MalformedTitleException $e ) { |
155 | return [ 'titlefield' => null, 'error' => 'linter-invalid-title' ]; |
156 | } |
157 | |
158 | // The drop-down namespace defaults to 'all' which is returned as a null, indicating match all namespaces. |
159 | // If 'main' is selected in the drop-down, int 0 is returned. Other namespaces are returned as int values > 0. |
160 | // |
161 | // If the user does not specify a namespace in the title text box, parseTitle sets it to int 0 as the default. |
162 | // If the user entered ':' (main) namespace as the namespace prefix of a title such as ":MyPageTitle", |
163 | // parseTitle will also return int 0 as the namespace. Other valid system namespaces entered as prefixes |
164 | // in the title text box are returned by parseTitle as int values > 0. |
165 | // To determine if the user entered the ':' (main) namespace when int 0 is returned, a separate check for |
166 | // the substring ':' at offset 0 must be performed. |
167 | |
168 | $titleNamespace = $titleElements->getNamespace(); |
169 | // Determine if the user entered ':' (resolves to main) as the namespace part of the title, |
170 | // or was it was set by default by parseTitle() to 0, but the user intended to search across 'all' namespaces. |
171 | if ( $titleNamespace === 0 && $title[0] !== ':' ) { |
172 | $titleNamespace = null; |
173 | } |
174 | |
175 | if ( $namespaces && $titleNamespace !== null && !in_array( $titleNamespace, $namespaces ) ) { |
176 | // Show the namespace mismatch error if the namespaces specified in drop-down and title text do not match. |
177 | return [ 'titlefield' => null, 'error' => 'linter-namespace-mismatch' ]; |
178 | } |
179 | |
180 | // If no namespaces are selected (null), return the namespace from the title text |
181 | $namespaces = $namespaces ?: [ $titleNamespace ]; |
182 | |
183 | return [ 'titlefield' => $titleElements->getDBkey(), 'namespace' => $namespaces ]; |
184 | } |
185 | |
186 | /** |
187 | * @param OutputPage $out |
188 | * @param string|null $message |
189 | */ |
190 | private function displayError( $out, $message ) { |
191 | $out->addHTML( |
192 | Html::element( 'span', [ 'class' => 'error' ], |
193 | $this->msg( $message )->text() ) |
194 | ); |
195 | } |
196 | |
197 | /** |
198 | * Extract namespace settings from the request object, |
199 | * returning an array of namespace id numbers |
200 | * |
201 | * @param WebRequest $request |
202 | * @return array |
203 | */ |
204 | protected function findNamespaces( $request ) { |
205 | $namespaces = []; |
206 | $activeNamespaces = array_keys( |
207 | $this->namespaceInfo->getCanonicalNamespaces() |
208 | ); |
209 | // Remove -2 = "media" and -1 = "Special" namespace elements |
210 | $activeNamespaces = array_filter( $activeNamespaces, |
211 | static function ( $x ) { |
212 | return $x >= 0; |
213 | } |
214 | ); |
215 | if ( $request->getCheck( 'wpNamespaceRestrictions' ) ) { |
216 | $namespaceRequestValues = $request->getRawVal( 'wpNamespaceRestrictions' ); |
217 | $namespaceIDs = array_map( 'intval', explode( "\n", $namespaceRequestValues ) ); |
218 | // Security measure: only allow active namespace IDs to reach the query |
219 | $namespaces = array_values( array_intersect( $activeNamespaces, $namespaceIDs ) ); |
220 | } |
221 | return $namespaces; |
222 | } |
223 | |
224 | /** |
225 | * @param string|null $subPage |
226 | */ |
227 | public function execute( $subPage ) { |
228 | $request = $this->getRequest(); |
229 | $out = $this->getOutput(); |
230 | |
231 | $params = $request->getQueryValues(); |
232 | |
233 | $this->setHeaders(); |
234 | $this->outputHeader( $subPage || isset( $params[ 'titlesearch' ] ) ? 'disable-summary' : '' ); |
235 | |
236 | $namespaces = $this->findNamespaces( $request ); |
237 | |
238 | $exactMatch = $request->getBool( 'exactmatch', true ); |
239 | $tagName = $this->getRequest()->getText( 'tag' ); |
240 | // map command line tag name through an associative array to protect request from an SQL injection security risk |
241 | $htmlTags = new HtmlTags( $this ); |
242 | $allowedHtmlTags = $htmlTags->getAllowedHTMLTags(); |
243 | $tag = $allowedHtmlTags[ $tagName ] ?? 'all'; |
244 | $template = $this->getRequest()->getText( 'template' ); |
245 | |
246 | // If the request contains a 'titlesearch' parameter, then the user entered a page title |
247 | // or just the first few characters of the title. They also may have entered the first few characters |
248 | // of a custom namespace (just the text before a ':') to search for and pressed the associated Submit button. |
249 | // Added the pageback parameter to inform the code that the '<- Special:LintErrors' link had been used to allow |
250 | // the UI to redisplay with previous form values, instead of just resubmitting the query. |
251 | if ( $subPage === null && isset( $params[ 'titlesearch' ] ) && !isset( $params[ 'pageback'] ) ) { |
252 | unset( $params[ 'title' ] ); |
253 | $params = array_merge( [ 'pageback' => true ], $params ); |
254 | $out->addBacklinkSubtitle( $this->getPageTitle(), $params ); |
255 | |
256 | $title = $request->getText( 'titlesearch' ); |
257 | $titleSearch = $this->cleanTitle( $title, $namespaces ); |
258 | |
259 | if ( $titleSearch[ 'titlefield' ] !== null ) { |
260 | $out->setPageTitleMsg( $this->msg( 'linter-prefix-search-subpage', $titleSearch[ 'titlefield' ] ) ); |
261 | |
262 | $pager = new LintErrorsPager( |
263 | $this->getContext(), |
264 | $this->categoryManager, |
265 | $this->linkCache, |
266 | $this->getLinkRenderer(), |
267 | $this->permissionManager, |
268 | null, |
269 | $namespaces, |
270 | $exactMatch, $titleSearch[ 'titlefield' ], $template, $tag |
271 | ); |
272 | $out->addParserOutput( $pager->getFullOutput() ); |
273 | } else { |
274 | $this->displayError( $out, $titleSearch[ 'error' ] ); |
275 | } |
276 | return; |
277 | } |
278 | |
279 | if ( in_array( $subPage, array_merge( |
280 | $this->categoryManager->getVisibleCategories(), |
281 | $this->categoryManager->getInvisibleCategories() |
282 | ) ) ) { |
283 | $this->category = $subPage; |
284 | } |
285 | |
286 | if ( !$this->category ) { |
287 | $this->addHelpLink( 'Help:Extension:Linter' ); |
288 | $this->showCategoryListings(); |
289 | } else { |
290 | $this->addHelpLink( "Help:Lint_errors/{$this->category}" ); |
291 | $out->setPageTitleMsg( |
292 | $this->msg( 'linterrors-subpage', |
293 | $this->msg( "linter-category-{$this->category}" )->text() |
294 | ) |
295 | ); |
296 | $out->addBacklinkSubtitle( $this->getPageTitle() ); |
297 | |
298 | $title = $request->getText( 'titlecategorysearch' ); |
299 | // For category-based searches, allow an undefined title to display all records |
300 | if ( $title === '' ) { |
301 | $titleCategorySearch = [ 'titlefield' => '', 'namespace' => $namespaces, 'pageid' => null ]; |
302 | } else { |
303 | $titleCategorySearch = $this->cleanTitle( $title, $namespaces ); |
304 | } |
305 | |
306 | if ( $titleCategorySearch[ 'titlefield' ] !== null ) { |
307 | $this->showFilterForm( 'titlecategorysearch' ); |
308 | $pager = new LintErrorsPager( |
309 | $this->getContext(), |
310 | $this->categoryManager, |
311 | $this->linkCache, |
312 | $this->getLinkRenderer(), |
313 | $this->permissionManager, |
314 | $this->category, |
315 | $namespaces, |
316 | $exactMatch, $titleCategorySearch[ 'titlefield' ], $template, $tag |
317 | ); |
318 | $out->addParserOutput( $pager->getFullOutput() ); |
319 | } else { |
320 | $this->displayError( $out, $titleCategorySearch[ 'error' ] ); |
321 | } |
322 | } |
323 | } |
324 | |
325 | /** |
326 | * @param string $priority |
327 | * @param int[] $totals name => count |
328 | * @param string[] $categories |
329 | */ |
330 | private function displayList( $priority, $totals, array $categories ) { |
331 | $out = $this->getOutput(); |
332 | $msgName = 'linter-heading-' . $priority . '-priority'; |
333 | $out->addHTML( Html::element( 'h2', [], $this->msg( $msgName )->text() ) ); |
334 | $out->addHTML( $this->buildCategoryList( $categories, $totals ) ); |
335 | } |
336 | |
337 | /** |
338 | */ |
339 | private function displaySearchPage() { |
340 | $out = $this->getOutput(); |
341 | $out->addHTML( Html::element( 'h2', [], |
342 | $this->msg( "linter-lints-prefix-search-page-desc" )->text() ) ); |
343 | $this->showFilterForm( 'titlesearch' ); |
344 | } |
345 | |
346 | private function showCategoryListings() { |
347 | $totals = $this->totalsLookup->getTotals(); |
348 | |
349 | // Display lint issues by priority |
350 | $this->displayList( 'high', $totals, $this->categoryManager->getHighPriority() ); |
351 | $this->displayList( 'medium', $totals, $this->categoryManager->getMediumPriority() ); |
352 | $this->displayList( 'low', $totals, $this->categoryManager->getLowPriority() ); |
353 | |
354 | $this->displaySearchPage(); |
355 | } |
356 | |
357 | /** |
358 | * @param string[] $cats |
359 | * @param int[] $totals name => count |
360 | * @return string |
361 | */ |
362 | private function buildCategoryList( array $cats, array $totals ) { |
363 | $linkRenderer = $this->getLinkRenderer(); |
364 | $html = Html::openElement( 'ul' ) . "\n"; |
365 | foreach ( $cats as $cat ) { |
366 | $html .= Html::rawElement( 'li', [], $linkRenderer->makeKnownLink( |
367 | $this->getPageTitle( $cat ), |
368 | $this->msg( "linter-category-$cat" )->text() |
369 | ) . ' ' . Html::element( 'bdi', [], |
370 | $this->msg( "linter-numerrors" )->numParams( $totals[$cat] )->text() |
371 | ) ) . "\n"; |
372 | } |
373 | $html .= Html::closeElement( 'ul' ); |
374 | |
375 | return $html; |
376 | } |
377 | |
378 | /** @inheritDoc */ |
379 | public function getGroupName() { |
380 | return 'maintenance'; |
381 | } |
382 | |
383 | /** |
384 | * @return string[] |
385 | */ |
386 | protected function getSubpagesForPrefixSearch() { |
387 | return $this->categoryManager->getVisibleCategories(); |
388 | } |
389 | |
390 | } |