MediaWiki master
SpecialTags.php
Go to the documentation of this file.
1<?php
24namespace MediaWiki\Specials;
25
26use ChangeTags;
33use Xml;
34
40class SpecialTags extends SpecialPage {
41
46
51
56 private ChangeTagsStore $changeTagsStore;
57
58 public function __construct( ChangeTagsStore $changeTagsStore ) {
59 parent::__construct( 'Tags' );
60 $this->changeTagsStore = $changeTagsStore;
61 }
62
63 public function execute( $par ) {
64 $this->setHeaders();
65 $this->outputHeader();
66 $this->addHelpLink( 'Manual:Tags' );
67
68 $request = $this->getRequest();
69 switch ( $par ) {
70 case 'delete':
71 $this->showDeleteTagForm( $request->getVal( 'tag' ) );
72 break;
73 case 'activate':
74 $this->showActivateDeactivateForm( $request->getVal( 'tag' ), true );
75 break;
76 case 'deactivate':
77 $this->showActivateDeactivateForm( $request->getVal( 'tag' ), false );
78 break;
79 case 'create':
80 // fall through, thanks to HTMLForm's logic
81 default:
82 $this->showTagList();
83 break;
84 }
85 }
86
87 private function showTagList() {
88 $out = $this->getOutput();
89 $out->setPageTitleMsg( $this->msg( 'tags-title' ) );
90 $out->wrapWikiMsg( "<div class='mw-tags-intro'>\n$1\n</div>", 'tags-intro' );
91
92 $authority = $this->getAuthority();
93 $userCanManage = $authority->isAllowed( 'managechangetags' );
94 $userCanDelete = $authority->isAllowed( 'deletechangetags' );
95 $userCanEditInterface = $authority->isAllowed( 'editinterface' );
96
97 // Show form to create a tag
98 if ( $userCanManage ) {
99 $fields = [
100 'Tag' => [
101 'type' => 'text',
102 'label' => $this->msg( 'tags-create-tag-name' )->plain(),
103 'required' => true,
104 ],
105 'Reason' => [
106 'type' => 'text',
107 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
108 'label' => $this->msg( 'tags-create-reason' )->plain(),
109 'size' => 50,
110 ],
111 'IgnoreWarnings' => [
112 'type' => 'hidden',
113 ],
114 ];
115
116 HTMLForm::factory( 'ooui', $fields, $this->getContext() )
117 ->setAction( $this->getPageTitle( 'create' )->getLocalURL() )
118 ->setWrapperLegendMsg( 'tags-create-heading' )
119 ->setHeaderHtml( $this->msg( 'tags-create-explanation' )->parseAsBlock() )
120 ->setSubmitCallback( [ $this, 'processCreateTagForm' ] )
121 ->setSubmitTextMsg( 'tags-create-submit' )
122 ->show();
123
124 // If processCreateTagForm generated a redirect, there's no point
125 // continuing with this, as the user is just going to end up getting sent
126 // somewhere else. Additionally, if we keep going here, we end up
127 // populating the memcache of tag data (see ChangeTags::listDefinedTags)
128 // with out-of-date data from the replica DB, because the replica DB hasn't caught
129 // up to the fact that a new tag has been created as part of an implicit,
130 // as yet uncommitted transaction on primary DB.
131 if ( $out->getRedirect() !== '' ) {
132 return;
133 }
134 }
135
136 // Used to get hitcounts for #doTagRow()
137 $tagStats = $this->changeTagsStore->tagUsageStatistics();
138
139 // Used in #doTagRow()
140 $this->explicitlyDefinedTags = array_fill_keys(
141 $this->changeTagsStore->listExplicitlyDefinedTags(), true );
142 $this->softwareDefinedTags = array_fill_keys(
143 $this->changeTagsStore->listSoftwareDefinedTags(), true );
144
145 // List all defined tags, even if they were never applied
146 $definedTags = array_keys( $this->explicitlyDefinedTags + $this->softwareDefinedTags );
147
148 // Show header only if there exists at least one tag
149 if ( !$tagStats && !$definedTags ) {
150 return;
151 }
152
153 // Write the headers
154 $thead = Xml::tags( 'tr', null, Xml::tags( 'th', null, $this->msg( 'tags-tag' )->parse() ) .
155 Xml::tags( 'th', null, $this->msg( 'tags-display-header' )->parse() ) .
156 Xml::tags( 'th', null, $this->msg( 'tags-description-header' )->parse() ) .
157 Xml::tags( 'th', null, $this->msg( 'tags-source-header' )->parse() ) .
158 Xml::tags( 'th', null, $this->msg( 'tags-active-header' )->parse() ) .
159 Xml::tags( 'th', null, $this->msg( 'tags-hitcount-header' )->parse() ) .
160 ( ( $userCanManage || $userCanDelete ) ?
161 Xml::tags( 'th', [ 'class' => 'unsortable' ],
162 $this->msg( 'tags-actions-header' )->parse() ) :
163 '' )
164 );
165
166 $tbody = '';
167 // Used in #doTagRow()
168 $this->softwareActivatedTags = array_fill_keys(
169 $this->changeTagsStore->listSoftwareActivatedTags(), true );
170
171 // Insert tags that have been applied at least once
172 foreach ( $tagStats as $tag => $hitcount ) {
173 $tbody .= $this->doTagRow( $tag, $hitcount, $userCanManage,
174 $userCanDelete, $userCanEditInterface );
175 }
176 // Insert tags defined somewhere but never applied
177 foreach ( $definedTags as $tag ) {
178 if ( !isset( $tagStats[$tag] ) ) {
179 $tbody .= $this->doTagRow( $tag, 0, $userCanManage, $userCanDelete, $userCanEditInterface );
180 }
181 }
182
183 $out->addModuleStyles( [
184 'jquery.tablesorter.styles',
185 'mediawiki.pager.styles'
186 ] );
187 $out->addModules( 'jquery.tablesorter' );
188 $out->addHTML( Xml::tags(
189 'table',
190 [ 'class' => 'mw-datatable sortable mw-tags-table' ],
191 Xml::tags( 'thead', null, $thead ) .
192 Xml::tags( 'tbody', null, $tbody )
193 ) );
194 }
195
196 private function doTagRow(
197 $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks
198 ) {
199 $newRow = '';
200 $newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, $tag ) );
201
202 $linkRenderer = $this->getLinkRenderer();
203 $disp = ChangeTags::tagDescription( $tag, $this->getContext() );
204 if ( $disp === false ) {
205 $disp = Xml::element( 'em', null, $this->msg( 'tags-hidden' )->text() );
206 }
207 if ( $showEditLinks ) {
208 $disp .= ' ';
209 $editLink = $linkRenderer->makeLink(
210 $this->msg( "tag-$tag" )->inContentLanguage()->getTitle(),
211 $this->msg( 'tags-edit' )->text(),
212 [],
213 [ 'action' => 'edit' ]
214 );
215 $disp .= $this->msg( 'parentheses' )->rawParams( $editLink )->escaped();
216 }
217 $newRow .= Xml::tags( 'td', null, $disp );
218
219 $msg = $this->msg( "tag-$tag-description" );
220 $desc = !$msg->exists() ? '' : $msg->parse();
221 if ( $showEditLinks ) {
222 $desc .= ' ';
223 $editDescLink = $linkRenderer->makeLink(
224 $this->msg( "tag-$tag-description" )->inContentLanguage()->getTitle(),
225 $this->msg( 'tags-edit' )->text(),
226 [],
227 [ 'action' => 'edit' ]
228 );
229 $desc .= $this->msg( 'parentheses' )->rawParams( $editDescLink )->escaped();
230 }
231 $newRow .= Xml::tags( 'td', null, $desc );
232
233 $sourceMsgs = [];
234 $isSoftware = isset( $this->softwareDefinedTags[$tag] );
235 $isExplicit = isset( $this->explicitlyDefinedTags[$tag] );
236 if ( $isSoftware ) {
237 // TODO: Rename this message
238 $sourceMsgs[] = $this->msg( 'tags-source-extension' )->escaped();
239 }
240 if ( $isExplicit ) {
241 $sourceMsgs[] = $this->msg( 'tags-source-manual' )->escaped();
242 }
243 if ( !$sourceMsgs ) {
244 $sourceMsgs[] = $this->msg( 'tags-source-none' )->escaped();
245 }
246 $newRow .= Xml::tags( 'td', null, implode( Xml::element( 'br' ), $sourceMsgs ) );
247
248 $isActive = $isExplicit || isset( $this->softwareActivatedTags[$tag] );
249 $activeMsg = ( $isActive ? 'tags-active-yes' : 'tags-active-no' );
250 $newRow .= Xml::tags( 'td', null, $this->msg( $activeMsg )->escaped() );
251
252 $hitcountLabelMsg = $this->msg( 'tags-hitcount' )->numParams( $hitcount );
253 if ( $this->getConfig()->get( MainConfigNames::UseTagFilter ) ) {
254 $hitcountLabel = $linkRenderer->makeLink(
255 SpecialPage::getTitleFor( 'Recentchanges' ),
256 $hitcountLabelMsg->text(),
257 [],
258 [ 'tagfilter' => $tag ]
259 );
260 } else {
261 $hitcountLabel = $hitcountLabelMsg->escaped();
262 }
263
264 // add raw $hitcount for sorting, because tags-hitcount contains numbers and letters
265 $newRow .= Xml::tags( 'td', [ 'data-sort-value' => $hitcount ], $hitcountLabel );
266
267 $actionLinks = [];
268
269 if ( $showDeleteActions && ChangeTags::canDeleteTag( $tag )->isOK() ) {
270 $actionLinks[] = $linkRenderer->makeKnownLink(
271 $this->getPageTitle( 'delete' ),
272 $this->msg( 'tags-delete' )->text(),
273 [],
274 [ 'tag' => $tag ] );
275 }
276
277 if ( $showManageActions ) { // we've already checked that the user had the requisite userright
278 if ( ChangeTags::canActivateTag( $tag )->isOK() ) {
279 $actionLinks[] = $linkRenderer->makeKnownLink(
280 $this->getPageTitle( 'activate' ),
281 $this->msg( 'tags-activate' )->text(),
282 [],
283 [ 'tag' => $tag ] );
284 }
285
286 if ( ChangeTags::canDeactivateTag( $tag )->isOK() ) {
287 $actionLinks[] = $linkRenderer->makeKnownLink(
288 $this->getPageTitle( 'deactivate' ),
289 $this->msg( 'tags-deactivate' )->text(),
290 [],
291 [ 'tag' => $tag ] );
292 }
293 }
294
295 if ( $showDeleteActions || $showManageActions ) {
296 $newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) );
297 }
298
299 return Xml::tags( 'tr', null, $newRow ) . "\n";
300 }
301
302 public function processCreateTagForm( array $data, HTMLForm $form ) {
303 $context = $form->getContext();
304 $out = $context->getOutput();
305
306 $tag = trim( strval( $data['Tag'] ) );
307 $ignoreWarnings = isset( $data['IgnoreWarnings'] ) && $data['IgnoreWarnings'] === '1';
308 $status = ChangeTags::createTagWithChecks( $tag, $data['Reason'],
309 $context->getAuthority(), $ignoreWarnings );
310
311 if ( $status->isGood() ) {
312 $out->redirect( $this->getPageTitle()->getLocalURL() );
313 return true;
314 } elseif ( $status->isOK() ) {
315 // We have some warnings, so we adjust the form for confirmation.
316 // This would override the existing field and its default value.
317 $form->addFields( [
318 'IgnoreWarnings' => [
319 'type' => 'hidden',
320 'default' => '1',
321 ],
322 ] );
323
324 $headerText = $this->msg( 'tags-create-warnings-above', $tag,
325 count( $status->getWarningsArray() ) )->parseAsBlock() .
326 $out->parseAsInterface( $status->getWikiText() ) .
327 $this->msg( 'tags-create-warnings-below' )->parseAsBlock();
328
329 $form->setHeaderHtml( $headerText )
330 ->setSubmitTextMsg( 'htmlform-yes' );
331
332 $out->addBacklinkSubtitle( $this->getPageTitle() );
333 return false;
334 } else {
335 $out->wrapWikiTextAsInterface( 'error', $status->getWikiText() );
336 return false;
337 }
338 }
339
340 protected function showDeleteTagForm( $tag ) {
341 $authority = $this->getAuthority();
342 if ( !$authority->isAllowed( 'deletechangetags' ) ) {
343 throw new PermissionsError( 'deletechangetags' );
344 }
345
346 $out = $this->getOutput();
347 $out->setPageTitleMsg( $this->msg( 'tags-delete-title' ) );
348 $out->addBacklinkSubtitle( $this->getPageTitle() );
349
350 // is the tag actually able to be deleted?
351 $canDeleteResult = ChangeTags::canDeleteTag( $tag, $authority );
352 if ( !$canDeleteResult->isGood() ) {
353 $out->wrapWikiTextAsInterface( 'error', $canDeleteResult->getWikiText() );
354 if ( !$canDeleteResult->isOK() ) {
355 return;
356 }
357 }
358
359 $preText = $this->msg( 'tags-delete-explanation-initial', $tag )->parseAsBlock();
360 $tagUsage = $this->changeTagsStore->tagUsageStatistics();
361 if ( isset( $tagUsage[$tag] ) && $tagUsage[$tag] > 0 ) {
362 $preText .= $this->msg( 'tags-delete-explanation-in-use', $tag,
363 $tagUsage[$tag] )->parseAsBlock();
364 }
365 $preText .= $this->msg( 'tags-delete-explanation-warning', $tag )->parseAsBlock();
366
367 // see if the tag is in use
368 $this->softwareActivatedTags = array_fill_keys(
369 $this->changeTagsStore->listSoftwareActivatedTags(), true );
370 if ( isset( $this->softwareActivatedTags[$tag] ) ) {
371 $preText .= $this->msg( 'tags-delete-explanation-active', $tag )->parseAsBlock();
372 }
373
374 $fields = [];
375 $fields['Reason'] = [
376 'type' => 'text',
377 'label' => $this->msg( 'tags-delete-reason' )->plain(),
378 'size' => 50,
379 ];
380 $fields['HiddenTag'] = [
381 'type' => 'hidden',
382 'name' => 'tag',
383 'default' => $tag,
384 'required' => true,
385 ];
386
387 HTMLForm::factory( 'ooui', $fields, $this->getContext() )
388 ->setAction( $this->getPageTitle( 'delete' )->getLocalURL() )
389 ->setSubmitCallback( function ( $data, $form ) {
390 return $this->processTagForm( $data, $form, 'delete' );
391 } )
392 ->setSubmitTextMsg( 'tags-delete-submit' )
393 ->setSubmitDestructive()
394 ->addPreHtml( $preText )
395 ->show();
396 }
397
398 protected function showActivateDeactivateForm( $tag, $activate ) {
399 $actionStr = $activate ? 'activate' : 'deactivate';
400
401 $authority = $this->getAuthority();
402 if ( !$authority->isAllowed( 'managechangetags' ) ) {
403 throw new PermissionsError( 'managechangetags' );
404 }
405
406 $out = $this->getOutput();
407 // tags-activate-title, tags-deactivate-title
408 $out->setPageTitleMsg( $this->msg( "tags-$actionStr-title" ) );
409 $out->addBacklinkSubtitle( $this->getPageTitle() );
410
411 // is it possible to do this?
412 if ( $activate ) {
413 $result = ChangeTags::canActivateTag( $tag, $authority );
414 } else {
415 $result = ChangeTags::canDeactivateTag( $tag, $authority );
416 }
417 if ( !$result->isGood() ) {
418 $out->wrapWikiTextAsInterface( 'error', $result->getWikiText() );
419 if ( !$result->isOK() ) {
420 return;
421 }
422 }
423
424 // tags-activate-question, tags-deactivate-question
425 $preText = $this->msg( "tags-$actionStr-question", $tag )->parseAsBlock();
426
427 $fields = [];
428 // tags-activate-reason, tags-deactivate-reason
429 $fields['Reason'] = [
430 'type' => 'text',
431 'label' => $this->msg( "tags-$actionStr-reason" )->plain(),
432 'size' => 50,
433 ];
434 $fields['HiddenTag'] = [
435 'type' => 'hidden',
436 'name' => 'tag',
437 'default' => $tag,
438 'required' => true,
439 ];
440
441 HTMLForm::factory( 'ooui', $fields, $this->getContext() )
442 ->setAction( $this->getPageTitle( $actionStr )->getLocalURL() )
443 ->setSubmitCallback( function ( $data, $form ) use ( $actionStr ) {
444 return $this->processTagForm( $data, $form, $actionStr );
445 } )
446 // tags-activate-submit, tags-deactivate-submit
447 ->setSubmitTextMsg( "tags-$actionStr-submit" )
448 ->addPreHtml( $preText )
449 ->show();
450 }
451
458 public function processTagForm( array $data, HTMLForm $form, string $action ) {
459 $context = $form->getContext();
460 $out = $context->getOutput();
461
462 $tag = $data['HiddenTag'];
463 // activateTagWithChecks, deactivateTagWithChecks, deleteTagWithChecks
464 $status = call_user_func( [ ChangeTags::class, "{$action}TagWithChecks" ],
465 $tag, $data['Reason'], $context->getUser(), true );
466
467 if ( $status->isGood() ) {
468 $out->redirect( $this->getPageTitle()->getLocalURL() );
469 return true;
470 } elseif ( $status->isOK() && $action === 'delete' ) {
471 // deletion succeeded, but hooks raised a warning
472 $out->addWikiTextAsInterface( $this->msg( 'tags-delete-warnings-after-delete', $tag,
473 count( $status->getWarningsArray() ) )->text() . "\n" .
474 $status->getWikitext() );
475 $out->addReturnTo( $this->getPageTitle() );
476 return true;
477 } else {
478 $out->wrapWikiTextAsInterface( 'error', $status->getWikitext() );
479 return false;
480 }
481 }
482
488 public function getSubpagesForPrefixSearch() {
489 // The subpages does not have an own form, so not listing it at the moment
490 return [
491 // 'delete',
492 // 'activate',
493 // 'deactivate',
494 // 'create',
495 ];
496 }
497
498 protected function getGroupName() {
499 return 'changes';
500 }
501}
502
507class_alias( SpecialTags::class, 'SpecialTags' );
static canDeactivateTag( $tag, Authority $performer=null)
Is it OK to allow the user to deactivate this tag?
static canActivateTag( $tag, Authority $performer=null)
Is it OK to allow the user to activate this tag?
static canDeleteTag( $tag, Authority $performer=null, int $flags=0)
Is it OK to allow the user to delete this tag?
static tagDescription( $tag, MessageLocalizer $context)
Get a short description for a tag.
static createTagWithChecks(string $tag, string $reason, Authority $performer, bool $ignoreWarnings=false, array $logEntryTags=[])
Creates a tag by adding it to change_tag_def table.
Gateway class for change_tags table.
Handle database storage of comments such as edit summaries and log reasons.
getContext()
Get the base IContextSource object.
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition HTMLForm.php:206
setHeaderHtml( $html, $section=null)
Set header HTML, inside the form.
Definition HTMLForm.php:963
addFields( $descriptor)
Add fields to the form.
Definition HTMLForm.php:466
A class containing constants representing the names of configuration variables.
const UseTagFilter
Name constant for the UseTagFilter setting, for use with Config::get()
Parent class for all special pages.
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
getPageTitle( $subpage=false)
Get a self-referential title object.
getConfig()
Shortcut to get main config object.
getContext()
Gets the context this SpecialPage is executed in.
getRequest()
Get the WebRequest being used for this instance.
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
getOutput()
Get the OutputPage being used for this instance.
getAuthority()
Shortcut to get the Authority executing this instance.
getLanguage()
Shortcut to get user's language.
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages Per default the message key is the canonical name o...
addHelpLink( $to, $overrideBaseUrl=false)
Adds help link with an icon via page indicators.
A special page that lists tags for edits.
execute( $par)
Default execute method Checks user permissions.
processTagForm(array $data, HTMLForm $form, string $action)
array $softwareActivatedTags
List of software activated tags.
processCreateTagForm(array $data, HTMLForm $form)
__construct(ChangeTagsStore $changeTagsStore)
array $explicitlyDefinedTags
List of explicitly defined tags.
showActivateDeactivateForm( $tag, $activate)
array $softwareDefinedTags
List of software defined tags.
getGroupName()
Under which header this special page is listed in Special:SpecialPages See messages 'specialpages-gro...
getSubpagesForPrefixSearch()
Return an array of subpages that this special page will accept.
Show an error when a user tries to do something they do not have the necessary permissions for.
Module of static functions for generating XML.
Definition Xml.php:33
static tags( $element, $attribs, $contents)
Same as Xml::element(), but does not escape contents.
Definition Xml.php:141
static element( $element, $attribs=null, $contents='', $allowShortTag=true)
Format an XML element with given attributes and, optionally, text content.
Definition Xml.php:50
This program is free software; you can redistribute it and/or modify it under the terms of the GNU Ge...