Merge "Add .pipeline/ with dev image variant"
[lhc/web/wiklou.git] / includes / specials / SpecialTags.php
1 <?php
2 /**
3 * Implements Special:Tags
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 SpecialPage
22 */
23
24 use MediaWiki\MediaWikiServices;
25
26 /**
27 * A special page that lists tags for edits
28 *
29 * @ingroup SpecialPage
30 */
31 class SpecialTags extends SpecialPage {
32
33 /**
34 * @var array List of explicitly defined tags
35 */
36 protected $explicitlyDefinedTags;
37
38 /**
39 * @var array List of software defined tags
40 */
41 protected $softwareDefinedTags;
42
43 /**
44 * @var array List of software activated tags
45 */
46 protected $softwareActivatedTags;
47
48 function __construct() {
49 parent::__construct( 'Tags' );
50 }
51
52 function execute( $par ) {
53 $this->setHeaders();
54 $this->outputHeader();
55 $this->addHelpLink( 'Manual:Tags' );
56
57 $request = $this->getRequest();
58 switch ( $par ) {
59 case 'delete':
60 $this->showDeleteTagForm( $request->getVal( 'tag' ) );
61 break;
62 case 'activate':
63 $this->showActivateDeactivateForm( $request->getVal( 'tag' ), true );
64 break;
65 case 'deactivate':
66 $this->showActivateDeactivateForm( $request->getVal( 'tag' ), false );
67 break;
68 case 'create':
69 // fall through, thanks to HTMLForm's logic
70 default:
71 $this->showTagList();
72 break;
73 }
74 }
75
76 function showTagList() {
77 $out = $this->getOutput();
78 $out->setPageTitle( $this->msg( 'tags-title' ) );
79 $out->wrapWikiMsg( "<div class='mw-tags-intro'>\n$1\n</div>", 'tags-intro' );
80
81 $user = $this->getUser();
82 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
83 $userCanManage = $permissionManager->userHasRight( $user, 'managechangetags' );
84 $userCanDelete = $permissionManager->userHasRight( $user, 'deletechangetags' );
85 $userCanEditInterface = $permissionManager->userHasRight( $user, 'editinterface' );
86
87 // Show form to create a tag
88 if ( $userCanManage ) {
89 $fields = [
90 'Tag' => [
91 'type' => 'text',
92 'label' => $this->msg( 'tags-create-tag-name' )->plain(),
93 'required' => true,
94 ],
95 'Reason' => [
96 'type' => 'text',
97 'label' => $this->msg( 'tags-create-reason' )->plain(),
98 'size' => 50,
99 ],
100 'IgnoreWarnings' => [
101 'type' => 'hidden',
102 ],
103 ];
104
105 $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
106 $form->setAction( $this->getPageTitle( 'create' )->getLocalURL() );
107 $form->setWrapperLegendMsg( 'tags-create-heading' );
108 $form->setHeaderText( $this->msg( 'tags-create-explanation' )->parseAsBlock() );
109 $form->setSubmitCallback( [ $this, 'processCreateTagForm' ] );
110 $form->setSubmitTextMsg( 'tags-create-submit' );
111 $form->show();
112
113 // If processCreateTagForm generated a redirect, there's no point
114 // continuing with this, as the user is just going to end up getting sent
115 // somewhere else. Additionally, if we keep going here, we end up
116 // populating the memcache of tag data (see ChangeTags::listDefinedTags)
117 // with out-of-date data from the replica DB, because the replica DB hasn't caught
118 // up to the fact that a new tag has been created as part of an implicit,
119 // as yet uncommitted transaction on master.
120 if ( $out->getRedirect() !== '' ) {
121 return;
122 }
123 }
124
125 // Used to get hitcounts for #doTagRow()
126 $tagStats = ChangeTags::tagUsageStatistics();
127
128 // Used in #doTagRow()
129 $this->explicitlyDefinedTags = array_fill_keys(
130 ChangeTags::listExplicitlyDefinedTags(), true );
131 $this->softwareDefinedTags = array_fill_keys(
132 ChangeTags::listSoftwareDefinedTags(), true );
133
134 // List all defined tags, even if they were never applied
135 $definedTags = array_keys( $this->explicitlyDefinedTags + $this->softwareDefinedTags );
136
137 // Show header only if there exists atleast one tag
138 if ( !$tagStats && !$definedTags ) {
139 return;
140 }
141
142 // Write the headers
143 $thead = Xml::tags( 'tr', null, Xml::tags( 'th', null, $this->msg( 'tags-tag' )->parse() ) .
144 Xml::tags( 'th', null, $this->msg( 'tags-display-header' )->parse() ) .
145 Xml::tags( 'th', null, $this->msg( 'tags-description-header' )->parse() ) .
146 Xml::tags( 'th', null, $this->msg( 'tags-source-header' )->parse() ) .
147 Xml::tags( 'th', null, $this->msg( 'tags-active-header' )->parse() ) .
148 Xml::tags( 'th', null, $this->msg( 'tags-hitcount-header' )->parse() ) .
149 ( ( $userCanManage || $userCanDelete ) ?
150 Xml::tags( 'th', [ 'class' => 'unsortable' ],
151 $this->msg( 'tags-actions-header' )->parse() ) :
152 '' )
153 );
154
155 $tbody = '';
156 // Used in #doTagRow()
157 $this->softwareActivatedTags = array_fill_keys(
158 ChangeTags::listSoftwareActivatedTags(), true );
159
160 // Insert tags that have been applied at least once
161 foreach ( $tagStats as $tag => $hitcount ) {
162 $tbody .= $this->doTagRow( $tag, $hitcount, $userCanManage,
163 $userCanDelete, $userCanEditInterface );
164 }
165 // Insert tags defined somewhere but never applied
166 foreach ( $definedTags as $tag ) {
167 if ( !isset( $tagStats[$tag] ) ) {
168 $tbody .= $this->doTagRow( $tag, 0, $userCanManage, $userCanDelete, $userCanEditInterface );
169 }
170 }
171
172 $out->addModuleStyles( 'jquery.tablesorter.styles' );
173 $out->addModules( 'jquery.tablesorter' );
174 $out->addHTML( Xml::tags(
175 'table',
176 [ 'class' => 'mw-datatable sortable mw-tags-table' ],
177 Xml::tags( 'thead', null, $thead ) .
178 Xml::tags( 'tbody', null, $tbody )
179 ) );
180 }
181
182 function doTagRow( $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks ) {
183 $newRow = '';
184 $newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, $tag ) );
185
186 $linkRenderer = $this->getLinkRenderer();
187 $disp = ChangeTags::tagDescription( $tag, $this->getContext() );
188 if ( $showEditLinks ) {
189 $disp .= ' ';
190 $editLink = $linkRenderer->makeLink(
191 $this->msg( "tag-$tag" )->inContentLanguage()->getTitle(),
192 $this->msg( 'tags-edit' )->text()
193 );
194 $disp .= $this->msg( 'parentheses' )->rawParams( $editLink )->escaped();
195 }
196 $newRow .= Xml::tags( 'td', null, $disp );
197
198 $msg = $this->msg( "tag-$tag-description" );
199 $desc = !$msg->exists() ? '' : $msg->parse();
200 if ( $showEditLinks ) {
201 $desc .= ' ';
202 $editDescLink = $linkRenderer->makeLink(
203 $this->msg( "tag-$tag-description" )->inContentLanguage()->getTitle(),
204 $this->msg( 'tags-edit' )->text()
205 );
206 $desc .= $this->msg( 'parentheses' )->rawParams( $editDescLink )->escaped();
207 }
208 $newRow .= Xml::tags( 'td', null, $desc );
209
210 $sourceMsgs = [];
211 $isSoftware = isset( $this->softwareDefinedTags[$tag] );
212 $isExplicit = isset( $this->explicitlyDefinedTags[$tag] );
213 if ( $isSoftware ) {
214 // TODO: Rename this message
215 $sourceMsgs[] = $this->msg( 'tags-source-extension' )->escaped();
216 }
217 if ( $isExplicit ) {
218 $sourceMsgs[] = $this->msg( 'tags-source-manual' )->escaped();
219 }
220 if ( !$sourceMsgs ) {
221 $sourceMsgs[] = $this->msg( 'tags-source-none' )->escaped();
222 }
223 $newRow .= Xml::tags( 'td', null, implode( Xml::element( 'br' ), $sourceMsgs ) );
224
225 $isActive = $isExplicit || isset( $this->softwareActivatedTags[$tag] );
226 $activeMsg = ( $isActive ? 'tags-active-yes' : 'tags-active-no' );
227 $newRow .= Xml::tags( 'td', null, $this->msg( $activeMsg )->escaped() );
228
229 $hitcountLabelMsg = $this->msg( 'tags-hitcount' )->numParams( $hitcount );
230 if ( $this->getConfig()->get( 'UseTagFilter' ) ) {
231 $hitcountLabel = $linkRenderer->makeLink(
232 SpecialPage::getTitleFor( 'Recentchanges' ),
233 $hitcountLabelMsg->text(),
234 [],
235 [ 'tagfilter' => $tag ]
236 );
237 } else {
238 $hitcountLabel = $hitcountLabelMsg->escaped();
239 }
240
241 // add raw $hitcount for sorting, because tags-hitcount contains numbers and letters
242 $newRow .= Xml::tags( 'td', [ 'data-sort-value' => $hitcount ], $hitcountLabel );
243
244 $actionLinks = [];
245
246 if ( $showDeleteActions && ChangeTags::canDeleteTag( $tag )->isOK() ) {
247 $actionLinks[] = $linkRenderer->makeKnownLink(
248 $this->getPageTitle( 'delete' ),
249 $this->msg( 'tags-delete' )->text(),
250 [],
251 [ 'tag' => $tag ] );
252 }
253
254 if ( $showManageActions ) { // we've already checked that the user had the requisite userright
255 if ( ChangeTags::canActivateTag( $tag )->isOK() ) {
256 $actionLinks[] = $linkRenderer->makeKnownLink(
257 $this->getPageTitle( 'activate' ),
258 $this->msg( 'tags-activate' )->text(),
259 [],
260 [ 'tag' => $tag ] );
261 }
262
263 if ( ChangeTags::canDeactivateTag( $tag )->isOK() ) {
264 $actionLinks[] = $linkRenderer->makeKnownLink(
265 $this->getPageTitle( 'deactivate' ),
266 $this->msg( 'tags-deactivate' )->text(),
267 [],
268 [ 'tag' => $tag ] );
269 }
270 }
271
272 if ( $showDeleteActions || $showManageActions ) {
273 $newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) );
274 }
275
276 return Xml::tags( 'tr', null, $newRow ) . "\n";
277 }
278
279 public function processCreateTagForm( array $data, HTMLForm $form ) {
280 $context = $form->getContext();
281 $out = $context->getOutput();
282
283 $tag = trim( strval( $data['Tag'] ) );
284 $ignoreWarnings = isset( $data['IgnoreWarnings'] ) && $data['IgnoreWarnings'] === '1';
285 $status = ChangeTags::createTagWithChecks( $tag, $data['Reason'],
286 $context->getUser(), $ignoreWarnings );
287
288 if ( $status->isGood() ) {
289 $out->redirect( $this->getPageTitle()->getLocalURL() );
290 return true;
291 } elseif ( $status->isOK() ) {
292 // we have some warnings, so we show a confirmation form
293 $fields = [
294 'Tag' => [
295 'type' => 'hidden',
296 'default' => $data['Tag'],
297 ],
298 'Reason' => [
299 'type' => 'hidden',
300 'default' => $data['Reason'],
301 ],
302 'IgnoreWarnings' => [
303 'type' => 'hidden',
304 'default' => '1',
305 ],
306 ];
307
308 // fool HTMLForm into thinking the form hasn't been submitted yet. Otherwise
309 // we get into an infinite loop!
310 $context->getRequest()->unsetVal( 'wpEditToken' );
311
312 $headerText = $this->msg( 'tags-create-warnings-above', $tag,
313 count( $status->getWarningsArray() ) )->parseAsBlock() .
314 $out->parseAsInterface( $status->getWikiText() ) .
315 $this->msg( 'tags-create-warnings-below' )->parseAsBlock();
316
317 $subform = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
318 $subform->setAction( $this->getPageTitle( 'create' )->getLocalURL() );
319 $subform->setWrapperLegendMsg( 'tags-create-heading' );
320 $subform->setHeaderText( $headerText );
321 $subform->setSubmitCallback( [ $this, 'processCreateTagForm' ] );
322 $subform->setSubmitTextMsg( 'htmlform-yes' );
323 $subform->show();
324
325 $out->addBacklinkSubtitle( $this->getPageTitle() );
326 return true;
327 } else {
328 $out->wrapWikiTextAsInterface( 'error', $status->getWikiText() );
329 return false;
330 }
331 }
332
333 protected function showDeleteTagForm( $tag ) {
334 $user = $this->getUser();
335 if ( !MediaWikiServices::getInstance()
336 ->getPermissionManager()
337 ->userHasRight( $user, 'deletechangetags' ) ) {
338 throw new PermissionsError( 'deletechangetags' );
339 }
340
341 $out = $this->getOutput();
342 $out->setPageTitle( $this->msg( 'tags-delete-title' ) );
343 $out->addBacklinkSubtitle( $this->getPageTitle() );
344
345 // is the tag actually able to be deleted?
346 $canDeleteResult = ChangeTags::canDeleteTag( $tag, $user );
347 if ( !$canDeleteResult->isGood() ) {
348 $out->wrapWikiTextAsInterface( 'error', $canDeleteResult->getWikiText() );
349 if ( !$canDeleteResult->isOK() ) {
350 return;
351 }
352 }
353
354 $preText = $this->msg( 'tags-delete-explanation-initial', $tag )->parseAsBlock();
355 $tagUsage = ChangeTags::tagUsageStatistics();
356 if ( isset( $tagUsage[$tag] ) && $tagUsage[$tag] > 0 ) {
357 $preText .= $this->msg( 'tags-delete-explanation-in-use', $tag,
358 $tagUsage[$tag] )->parseAsBlock();
359 }
360 $preText .= $this->msg( 'tags-delete-explanation-warning', $tag )->parseAsBlock();
361
362 // see if the tag is in use
363 $this->softwareActivatedTags = array_fill_keys(
364 ChangeTags::listSoftwareActivatedTags(), true );
365 if ( isset( $this->softwareActivatedTags[$tag] ) ) {
366 $preText .= $this->msg( 'tags-delete-explanation-active', $tag )->parseAsBlock();
367 }
368
369 $fields = [];
370 $fields['Reason'] = [
371 'type' => 'text',
372 'label' => $this->msg( 'tags-delete-reason' )->plain(),
373 'size' => 50,
374 ];
375 $fields['HiddenTag'] = [
376 'type' => 'hidden',
377 'name' => 'tag',
378 'default' => $tag,
379 'required' => true,
380 ];
381
382 $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
383 $form->setAction( $this->getPageTitle( 'delete' )->getLocalURL() );
384 // @phan-suppress-next-line PhanUndeclaredProperty
385 $form->tagAction = 'delete'; // custom property on HTMLForm object
386 $form->setSubmitCallback( [ $this, 'processTagForm' ] );
387 $form->setSubmitTextMsg( 'tags-delete-submit' );
388 $form->setSubmitDestructive(); // nasty!
389 $form->addPreText( $preText );
390 $form->show();
391 }
392
393 protected function showActivateDeactivateForm( $tag, $activate ) {
394 $actionStr = $activate ? 'activate' : 'deactivate';
395
396 $user = $this->getUser();
397 if ( !MediaWikiServices::getInstance()
398 ->getPermissionManager()
399 ->userHasRight( $user, 'managechangetags' ) ) {
400 throw new PermissionsError( 'managechangetags' );
401 }
402
403 $out = $this->getOutput();
404 // tags-activate-title, tags-deactivate-title
405 $out->setPageTitle( $this->msg( "tags-$actionStr-title" ) );
406 $out->addBacklinkSubtitle( $this->getPageTitle() );
407
408 // is it possible to do this?
409 $func = $activate ? 'canActivateTag' : 'canDeactivateTag';
410 $result = ChangeTags::$func( $tag, $user );
411 if ( !$result->isGood() ) {
412 $out->wrapWikiTextAsInterface( 'error', $result->getWikiText() );
413 if ( !$result->isOK() ) {
414 return;
415 }
416 }
417
418 // tags-activate-question, tags-deactivate-question
419 $preText = $this->msg( "tags-$actionStr-question", $tag )->parseAsBlock();
420
421 $fields = [];
422 // tags-activate-reason, tags-deactivate-reason
423 $fields['Reason'] = [
424 'type' => 'text',
425 'label' => $this->msg( "tags-$actionStr-reason" )->plain(),
426 'size' => 50,
427 ];
428 $fields['HiddenTag'] = [
429 'type' => 'hidden',
430 'name' => 'tag',
431 'default' => $tag,
432 'required' => true,
433 ];
434
435 $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
436 $form->setAction( $this->getPageTitle( $actionStr )->getLocalURL() );
437 // @phan-suppress-next-line PhanUndeclaredProperty
438 $form->tagAction = $actionStr;
439 $form->setSubmitCallback( [ $this, 'processTagForm' ] );
440 // tags-activate-submit, tags-deactivate-submit
441 $form->setSubmitTextMsg( "tags-$actionStr-submit" );
442 $form->addPreText( $preText );
443 $form->show();
444 }
445
446 /**
447 * @param array $data
448 * @param HTMLForm $form
449 * @return bool
450 * @suppress PhanUndeclaredProperty $form->tagAction
451 */
452 public function processTagForm( array $data, HTMLForm $form ) {
453 $context = $form->getContext();
454 $out = $context->getOutput();
455
456 $tag = $data['HiddenTag'];
457 $status = call_user_func( [ ChangeTags::class, "{$form->tagAction}TagWithChecks" ],
458 $tag, $data['Reason'], $context->getUser(), true );
459
460 if ( $status->isGood() ) {
461 $out->redirect( $this->getPageTitle()->getLocalURL() );
462 return true;
463 } elseif ( $status->isOK() && $form->tagAction === 'delete' ) {
464 // deletion succeeded, but hooks raised a warning
465 $out->addWikiTextAsInterface( $this->msg( 'tags-delete-warnings-after-delete', $tag,
466 count( $status->getWarningsArray() ) )->text() . "\n" .
467 $status->getWikitext() );
468 $out->addReturnTo( $this->getPageTitle() );
469 return true;
470 } else {
471 $out->wrapWikiTextAsInterface( 'error', $status->getWikitext() );
472 return false;
473 }
474 }
475
476 /**
477 * Return an array of subpages that this special page will accept.
478 *
479 * @return string[] subpages
480 */
481 public function getSubpagesForPrefixSearch() {
482 // The subpages does not have an own form, so not listing it at the moment
483 return [
484 // 'delete',
485 // 'activate',
486 // 'deactivate',
487 // 'create',
488 ];
489 }
490
491 protected function getGroupName() {
492 return 'changes';
493 }
494 }