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
21 namespace MediaWiki\Specials
;
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
;
34 * A special page that lists tags for edits
36 * @ingroup SpecialPage
38 class SpecialTags
extends SpecialPage
{
41 * @var array List of explicitly defined tags
43 protected $explicitlyDefinedTags;
46 * @var array List of software defined tags
48 protected $softwareDefinedTags;
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 ) {
63 $this->outputHeader();
64 $this->addHelpLink( 'Manual:Tags' );
65 $this->getOutput()->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
67 $request = $this->getRequest();
70 $this->showDeleteTagForm( $request->getVal( 'tag' ) );
73 $this->showActivateDeactivateForm( $request->getVal( 'tag' ), true );
76 $this->showActivateDeactivateForm( $request->getVal( 'tag' ), false );
79 // fall through, thanks to HTMLForm's logic
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 ) {
101 'label' => $this->msg( 'tags-create-tag-name' )->plain(),
106 'maxlength' => CommentStore
::COMMENT_CHARACTER_LIMIT
,
107 'label' => $this->msg( 'tags-create-reason' )->plain(),
110 'IgnoreWarnings' => [
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' )
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() !== '' ) {
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 ) {
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() ) :
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'
186 $out->addModules( 'jquery.tablesorter' );
187 $out->addHTML( Xml
::tags(
189 [ 'class' => 'mw-datatable sortable mw-tags-table' ],
190 Xml
::tags( 'thead', null, $thead ) .
191 Xml
::tags( 'tbody', null, $tbody )
195 private function doTagRow(
196 $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks
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 ) {
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 ] )
224 $newRow .= Xml
::tags( 'td', null, $disp );
226 $msg = $this->msg( "tag-$tag-description" );
227 $desc = !$msg->exists() ?
'' : $msg->parse();
228 if ( $showEditLinks ) {
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 );
241 $isSoftware = isset( $this->softwareDefinedTags
[$tag] );
242 $isExplicit = isset( $this->explicitlyDefinedTags
[$tag] );
244 // TODO: Rename this message
245 $sourceMsgs[] = $this->msg( 'tags-source-extension' )->escaped();
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 ]
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 );
276 if ( $showDeleteActions && ChangeTags
::canDeleteTag( $tag )->isOK() ) {
277 $actionLinks[] = $linkRenderer->makeKnownLink(
278 $this->getPageTitle( 'delete' ),
279 $this->msg( 'tags-delete' )->text(),
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(),
293 if ( ChangeTags
::canDeactivateTag( $tag )->isOK() ) {
294 $actionLinks[] = $linkRenderer->makeKnownLink(
295 $this->getPageTitle( 'deactivate' ),
296 $this->msg( 'tags-deactivate' )->text(),
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() );
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.
325 'IgnoreWarnings' => [
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() );
342 foreach ( $status->getMessages() as $msg ) {
343 $out->addHTML( Html
::errorBox(
344 $this->msg( $msg )->parse()
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()
369 if ( !$canDeleteResult->isOK() ) {
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();
390 $fields['Reason'] = [
392 'label' => $this->msg( 'tags-delete-reason' )->plain(),
395 $fields['HiddenTag'] = [
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 )
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?
428 $result = ChangeTags
::canActivateTag( $tag, $authority );
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()
438 if ( !$result->isOK() ) {
443 // tags-activate-question, tags-deactivate-question
444 $preText = $this->msg( "tags-$actionStr-question", $tag )->parseAsBlock();
447 // tags-activate-reason, tags-deactivate-reason
448 $fields['Reason'] = [
450 'label' => $this->msg( "tags-$actionStr-reason" )->plain(),
453 $fields['HiddenTag'] = [
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 )
473 * @param HTMLForm $form
474 * @param string $action
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() );
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() );
497 foreach ( $status->getMessages() as $msg ) {
498 $out->addHTML( Html
::errorBox(
499 $this->msg( $msg )->parse()
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
521 protected function getGroupName() {
527 * Retain the old class name for backwards compatibility.
528 * @deprecated since 1.41
530 class_alias( SpecialTags
::class, 'SpecialTags' );