Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / specials / SpecialTags.php
blob382f1e3193ea4b4549d4df337a72180874e6e731
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.
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.
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
18 * @file
21 namespace MediaWiki\Specials;
23 use ChangeTags;
24 use MediaWiki\ChangeTags\ChangeTagsStore;
25 use MediaWiki\CommentStore\CommentStore;
26 use MediaWiki\Html\Html;
27 use MediaWiki\HTMLForm\HTMLForm;
28 use MediaWiki\MainConfigNames;
29 use MediaWiki\SpecialPage\SpecialPage;
30 use MediaWiki\Xml\Xml;
31 use PermissionsError;
33 /**
34 * A special page that lists tags for edits
36 * @ingroup SpecialPage
38 class SpecialTags extends SpecialPage {
40 /**
41 * @var array List of explicitly defined tags
43 protected $explicitlyDefinedTags;
45 /**
46 * @var array List of software defined tags
48 protected $softwareDefinedTags;
50 /**
51 * @var array List of software activated tags
53 protected $softwareActivatedTags;
54 private ChangeTagsStore $changeTagsStore;
56 public function __construct( ChangeTagsStore $changeTagsStore ) {
57 parent::__construct( 'Tags' );
58 $this->changeTagsStore = $changeTagsStore;
61 public function execute( $par ) {
62 $this->setHeaders();
63 $this->outputHeader();
64 $this->addHelpLink( 'Manual:Tags' );
65 $this->getOutput()->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
67 $request = $this->getRequest();
68 switch ( $par ) {
69 case 'delete':
70 $this->showDeleteTagForm( $request->getVal( 'tag' ) );
71 break;
72 case 'activate':
73 $this->showActivateDeactivateForm( $request->getVal( 'tag' ), true );
74 break;
75 case 'deactivate':
76 $this->showActivateDeactivateForm( $request->getVal( 'tag' ), false );
77 break;
78 case 'create':
79 // fall through, thanks to HTMLForm's logic
80 default:
81 $this->showTagList();
82 break;
86 private function showTagList() {
87 $out = $this->getOutput();
88 $out->setPageTitleMsg( $this->msg( 'tags-title' ) );
89 $out->wrapWikiMsg( "<div class='mw-tags-intro'>\n$1\n</div>", 'tags-intro' );
91 $authority = $this->getAuthority();
92 $userCanManage = $authority->isAllowed( 'managechangetags' );
93 $userCanDelete = $authority->isAllowed( 'deletechangetags' );
94 $userCanEditInterface = $authority->isAllowed( 'editinterface' );
96 // Show form to create a tag
97 if ( $userCanManage ) {
98 $fields = [
99 'Tag' => [
100 'type' => 'text',
101 'label' => $this->msg( 'tags-create-tag-name' )->plain(),
102 'required' => true,
104 'Reason' => [
105 'type' => 'text',
106 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
107 'label' => $this->msg( 'tags-create-reason' )->plain(),
108 'size' => 50,
110 'IgnoreWarnings' => [
111 'type' => 'hidden',
115 HTMLForm::factory( 'ooui', $fields, $this->getContext() )
116 ->setAction( $this->getPageTitle( 'create' )->getLocalURL() )
117 ->setWrapperLegendMsg( 'tags-create-heading' )
118 ->setHeaderHtml( $this->msg( 'tags-create-explanation' )->parseAsBlock() )
119 ->setSubmitCallback( [ $this, 'processCreateTagForm' ] )
120 ->setSubmitTextMsg( 'tags-create-submit' )
121 ->show();
123 // If processCreateTagForm generated a redirect, there's no point
124 // continuing with this, as the user is just going to end up getting sent
125 // somewhere else. Additionally, if we keep going here, we end up
126 // populating the memcache of tag data (see ChangeTagsStore->listDefinedTags)
127 // with out-of-date data from the replica DB, because the replica DB hasn't caught
128 // up to the fact that a new tag has been created as part of an implicit,
129 // as yet uncommitted transaction on primary DB.
130 if ( $out->getRedirect() !== '' ) {
131 return;
135 // Used to get hitcounts for #doTagRow()
136 $tagStats = $this->changeTagsStore->tagUsageStatistics();
138 // Used in #doTagRow()
139 $this->explicitlyDefinedTags = array_fill_keys(
140 $this->changeTagsStore->listExplicitlyDefinedTags(), true );
141 $this->softwareDefinedTags = array_fill_keys(
142 $this->changeTagsStore->listSoftwareDefinedTags(), true );
144 // List all defined tags, even if they were never applied
145 $definedTags = array_keys( $this->explicitlyDefinedTags + $this->softwareDefinedTags );
147 // Show header only if there exists at least one tag
148 if ( !$tagStats && !$definedTags ) {
149 return;
152 // Write the headers
153 $thead = Xml::tags( 'tr', null, Xml::tags( 'th', null, $this->msg( 'tags-tag' )->parse() ) .
154 Xml::tags( 'th', null, $this->msg( 'tags-display-header' )->parse() ) .
155 Xml::tags( 'th', null, $this->msg( 'tags-description-header' )->parse() ) .
156 Xml::tags( 'th', null, $this->msg( 'tags-source-header' )->parse() ) .
157 Xml::tags( 'th', null, $this->msg( 'tags-active-header' )->parse() ) .
158 Xml::tags( 'th', null, $this->msg( 'tags-hitcount-header' )->parse() ) .
159 ( ( $userCanManage || $userCanDelete ) ?
160 Xml::tags( 'th', [ 'class' => 'unsortable' ],
161 $this->msg( 'tags-actions-header' )->parse() ) :
162 '' )
165 $tbody = '';
166 // Used in #doTagRow()
167 $this->softwareActivatedTags = array_fill_keys(
168 $this->changeTagsStore->listSoftwareActivatedTags(), true );
170 // Insert tags that have been applied at least once
171 foreach ( $tagStats as $tag => $hitcount ) {
172 $tbody .= $this->doTagRow( $tag, $hitcount, $userCanManage,
173 $userCanDelete, $userCanEditInterface );
175 // Insert tags defined somewhere but never applied
176 foreach ( $definedTags as $tag ) {
177 if ( !isset( $tagStats[$tag] ) ) {
178 $tbody .= $this->doTagRow( $tag, 0, $userCanManage, $userCanDelete, $userCanEditInterface );
182 $out->addModuleStyles( [
183 'jquery.tablesorter.styles',
184 'mediawiki.pager.styles'
185 ] );
186 $out->addModules( 'jquery.tablesorter' );
187 $out->addHTML( Xml::tags(
188 'table',
189 [ 'class' => 'mw-datatable sortable mw-tags-table' ],
190 Xml::tags( 'thead', null, $thead ) .
191 Xml::tags( 'tbody', null, $tbody )
192 ) );
195 private function doTagRow(
196 $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks
198 $newRow = '';
199 $newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, $tag ) );
201 $linkRenderer = $this->getLinkRenderer();
202 $disp = ChangeTags::tagDescription( $tag, $this->getContext() );
203 if ( $disp === false ) {
204 $disp = Xml::element( 'em', null, $this->msg( 'tags-hidden' )->text() );
206 if ( $showEditLinks ) {
207 $disp .= ' ';
208 $editLink = $linkRenderer->makeLink(
209 $this->msg( "tag-$tag" )->getTitle(),
210 $this->msg( 'tags-edit' )->text(),
212 [ 'action' => 'edit' ]
214 $helpEditLink = $linkRenderer->makeLink(
215 $this->msg( "tag-$tag-helppage" )->inContentLanguage()->getTitle(),
216 $this->msg( 'tags-helppage-edit' )->text(),
218 [ 'action' => 'edit' ]
220 $disp .= $this->msg( 'parentheses' )->rawParams(
221 $this->getLanguage()->pipeList( [ $editLink, $helpEditLink ] )
222 )->escaped();
224 $newRow .= Xml::tags( 'td', null, $disp );
226 $msg = $this->msg( "tag-$tag-description" );
227 $desc = !$msg->exists() ? '' : $msg->parse();
228 if ( $showEditLinks ) {
229 $desc .= ' ';
230 $editDescLink = $linkRenderer->makeLink(
231 $this->msg( "tag-$tag-description" )->inContentLanguage()->getTitle(),
232 $this->msg( 'tags-edit' )->text(),
234 [ 'action' => 'edit' ]
236 $desc .= $this->msg( 'parentheses' )->rawParams( $editDescLink )->escaped();
238 $newRow .= Xml::tags( 'td', null, $desc );
240 $sourceMsgs = [];
241 $isSoftware = isset( $this->softwareDefinedTags[$tag] );
242 $isExplicit = isset( $this->explicitlyDefinedTags[$tag] );
243 if ( $isSoftware ) {
244 // TODO: Rename this message
245 $sourceMsgs[] = $this->msg( 'tags-source-extension' )->escaped();
247 if ( $isExplicit ) {
248 $sourceMsgs[] = $this->msg( 'tags-source-manual' )->escaped();
250 if ( !$sourceMsgs ) {
251 $sourceMsgs[] = $this->msg( 'tags-source-none' )->escaped();
253 $newRow .= Xml::tags( 'td', null, implode( Xml::element( 'br' ), $sourceMsgs ) );
255 $isActive = $isExplicit || isset( $this->softwareActivatedTags[$tag] );
256 $activeMsg = ( $isActive ? 'tags-active-yes' : 'tags-active-no' );
257 $newRow .= Xml::tags( 'td', null, $this->msg( $activeMsg )->escaped() );
259 $hitcountLabelMsg = $this->msg( 'tags-hitcount' )->numParams( $hitcount );
260 if ( $this->getConfig()->get( MainConfigNames::UseTagFilter ) ) {
261 $hitcountLabel = $linkRenderer->makeLink(
262 SpecialPage::getTitleFor( 'Recentchanges' ),
263 $hitcountLabelMsg->text(),
265 [ 'tagfilter' => $tag ]
267 } else {
268 $hitcountLabel = $hitcountLabelMsg->escaped();
271 // add raw $hitcount for sorting, because tags-hitcount contains numbers and letters
272 $newRow .= Xml::tags( 'td', [ 'data-sort-value' => $hitcount ], $hitcountLabel );
274 $actionLinks = [];
276 if ( $showDeleteActions && ChangeTags::canDeleteTag( $tag )->isOK() ) {
277 $actionLinks[] = $linkRenderer->makeKnownLink(
278 $this->getPageTitle( 'delete' ),
279 $this->msg( 'tags-delete' )->text(),
281 [ 'tag' => $tag ] );
284 if ( $showManageActions ) { // we've already checked that the user had the requisite userright
285 if ( ChangeTags::canActivateTag( $tag )->isOK() ) {
286 $actionLinks[] = $linkRenderer->makeKnownLink(
287 $this->getPageTitle( 'activate' ),
288 $this->msg( 'tags-activate' )->text(),
290 [ 'tag' => $tag ] );
293 if ( ChangeTags::canDeactivateTag( $tag )->isOK() ) {
294 $actionLinks[] = $linkRenderer->makeKnownLink(
295 $this->getPageTitle( 'deactivate' ),
296 $this->msg( 'tags-deactivate' )->text(),
298 [ 'tag' => $tag ] );
302 if ( $showDeleteActions || $showManageActions ) {
303 $newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) );
306 return Xml::tags( 'tr', null, $newRow ) . "\n";
309 public function processCreateTagForm( array $data, HTMLForm $form ) {
310 $context = $form->getContext();
311 $out = $context->getOutput();
313 $tag = trim( strval( $data['Tag'] ) );
314 $ignoreWarnings = isset( $data['IgnoreWarnings'] ) && $data['IgnoreWarnings'] === '1';
315 $status = ChangeTags::createTagWithChecks( $tag, $data['Reason'],
316 $context->getAuthority(), $ignoreWarnings );
318 if ( $status->isGood() ) {
319 $out->redirect( $this->getPageTitle()->getLocalURL() );
320 return true;
321 } elseif ( $status->isOK() ) {
322 // We have some warnings, so we adjust the form for confirmation.
323 // This would override the existing field and its default value.
324 $form->addFields( [
325 'IgnoreWarnings' => [
326 'type' => 'hidden',
327 'default' => '1',
329 ] );
331 $headerText = $this->msg( 'tags-create-warnings-above', $tag,
332 count( $status->getMessages( 'warning' ) ) )->parseAsBlock() .
333 $out->parseAsInterface( $status->getWikiText() ) .
334 $this->msg( 'tags-create-warnings-below' )->parseAsBlock();
336 $form->setHeaderHtml( $headerText )
337 ->setSubmitTextMsg( 'htmlform-yes' );
339 $out->addBacklinkSubtitle( $this->getPageTitle() );
340 return false;
341 } else {
342 foreach ( $status->getMessages() as $msg ) {
343 $out->addHTML( Html::errorBox(
344 $this->msg( $msg )->parse()
345 ) );
347 return false;
351 protected function showDeleteTagForm( $tag ) {
352 $authority = $this->getAuthority();
353 if ( !$authority->isAllowed( 'deletechangetags' ) ) {
354 throw new PermissionsError( 'deletechangetags' );
357 $out = $this->getOutput();
358 $out->setPageTitleMsg( $this->msg( 'tags-delete-title' ) );
359 $out->addBacklinkSubtitle( $this->getPageTitle() );
361 // is the tag actually able to be deleted?
362 $canDeleteResult = ChangeTags::canDeleteTag( $tag, $authority );
363 if ( !$canDeleteResult->isGood() ) {
364 foreach ( $canDeleteResult->getMessages() as $msg ) {
365 $out->addHTML( Html::errorBox(
366 $this->msg( $msg )->parse()
367 ) );
369 if ( !$canDeleteResult->isOK() ) {
370 return;
374 $preText = $this->msg( 'tags-delete-explanation-initial', $tag )->parseAsBlock();
375 $tagUsage = $this->changeTagsStore->tagUsageStatistics();
376 if ( isset( $tagUsage[$tag] ) && $tagUsage[$tag] > 0 ) {
377 $preText .= $this->msg( 'tags-delete-explanation-in-use', $tag,
378 $tagUsage[$tag] )->parseAsBlock();
380 $preText .= $this->msg( 'tags-delete-explanation-warning', $tag )->parseAsBlock();
382 // see if the tag is in use
383 $this->softwareActivatedTags = array_fill_keys(
384 $this->changeTagsStore->listSoftwareActivatedTags(), true );
385 if ( isset( $this->softwareActivatedTags[$tag] ) ) {
386 $preText .= $this->msg( 'tags-delete-explanation-active', $tag )->parseAsBlock();
389 $fields = [];
390 $fields['Reason'] = [
391 'type' => 'text',
392 'label' => $this->msg( 'tags-delete-reason' )->plain(),
393 'size' => 50,
395 $fields['HiddenTag'] = [
396 'type' => 'hidden',
397 'name' => 'tag',
398 'default' => $tag,
399 'required' => true,
402 HTMLForm::factory( 'ooui', $fields, $this->getContext() )
403 ->setAction( $this->getPageTitle( 'delete' )->getLocalURL() )
404 ->setSubmitCallback( function ( $data, $form ) {
405 return $this->processTagForm( $data, $form, 'delete' );
407 ->setSubmitTextMsg( 'tags-delete-submit' )
408 ->setSubmitDestructive()
409 ->addPreHtml( $preText )
410 ->show();
413 protected function showActivateDeactivateForm( $tag, $activate ) {
414 $actionStr = $activate ? 'activate' : 'deactivate';
416 $authority = $this->getAuthority();
417 if ( !$authority->isAllowed( 'managechangetags' ) ) {
418 throw new PermissionsError( 'managechangetags' );
421 $out = $this->getOutput();
422 // tags-activate-title, tags-deactivate-title
423 $out->setPageTitleMsg( $this->msg( "tags-$actionStr-title" ) );
424 $out->addBacklinkSubtitle( $this->getPageTitle() );
426 // is it possible to do this?
427 if ( $activate ) {
428 $result = ChangeTags::canActivateTag( $tag, $authority );
429 } else {
430 $result = ChangeTags::canDeactivateTag( $tag, $authority );
432 if ( !$result->isGood() ) {
433 foreach ( $result->getMessages() as $msg ) {
434 $out->addHTML( Html::errorBox(
435 $this->msg( $msg )->parse()
436 ) );
438 if ( !$result->isOK() ) {
439 return;
443 // tags-activate-question, tags-deactivate-question
444 $preText = $this->msg( "tags-$actionStr-question", $tag )->parseAsBlock();
446 $fields = [];
447 // tags-activate-reason, tags-deactivate-reason
448 $fields['Reason'] = [
449 'type' => 'text',
450 'label' => $this->msg( "tags-$actionStr-reason" )->plain(),
451 'size' => 50,
453 $fields['HiddenTag'] = [
454 'type' => 'hidden',
455 'name' => 'tag',
456 'default' => $tag,
457 'required' => true,
460 HTMLForm::factory( 'ooui', $fields, $this->getContext() )
461 ->setAction( $this->getPageTitle( $actionStr )->getLocalURL() )
462 ->setSubmitCallback( function ( $data, $form ) use ( $actionStr ) {
463 return $this->processTagForm( $data, $form, $actionStr );
465 // tags-activate-submit, tags-deactivate-submit
466 ->setSubmitTextMsg( "tags-$actionStr-submit" )
467 ->addPreHtml( $preText )
468 ->show();
472 * @param array $data
473 * @param HTMLForm $form
474 * @param string $action
475 * @return bool
477 public function processTagForm( array $data, HTMLForm $form, string $action ) {
478 $context = $form->getContext();
479 $out = $context->getOutput();
481 $tag = $data['HiddenTag'];
482 // activateTagWithChecks, deactivateTagWithChecks, deleteTagWithChecks
483 $status = call_user_func( [ ChangeTags::class, "{$action}TagWithChecks" ],
484 $tag, $data['Reason'], $context->getUser(), true );
486 if ( $status->isGood() ) {
487 $out->redirect( $this->getPageTitle()->getLocalURL() );
488 return true;
489 } elseif ( $status->isOK() && $action === 'delete' ) {
490 // deletion succeeded, but hooks raised a warning
491 $out->addWikiTextAsInterface( $this->msg( 'tags-delete-warnings-after-delete', $tag,
492 count( $status->getMessages( 'warning' ) ) )->text() . "\n" .
493 $status->getWikitext() );
494 $out->addReturnTo( $this->getPageTitle() );
495 return true;
496 } else {
497 foreach ( $status->getMessages() as $msg ) {
498 $out->addHTML( Html::errorBox(
499 $this->msg( $msg )->parse()
500 ) );
502 return false;
507 * Return an array of subpages that this special page will accept.
509 * @return string[] subpages
511 public function getSubpagesForPrefixSearch() {
512 // The subpages does not have an own form, so not listing it at the moment
513 return [
514 // 'delete',
515 // 'activate',
516 // 'deactivate',
517 // 'create',
521 protected function getGroupName() {
522 return 'changes';
527 * Retain the old class name for backwards compatibility.
528 * @deprecated since 1.41
530 class_alias( SpecialTags::class, 'SpecialTags' );