MediaWiki master
SpecialTags.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Specials;
22
23use ChangeTags;
32
38class SpecialTags extends SpecialPage {
39
44
49
54 private ChangeTagsStore $changeTagsStore;
55
56 public function __construct( ChangeTagsStore $changeTagsStore ) {
57 parent::__construct( 'Tags' );
58 $this->changeTagsStore = $changeTagsStore;
59 }
60
61 public function execute( $par ) {
62 $this->setHeaders();
63 $this->outputHeader();
64 $this->addHelpLink( 'Manual:Tags' );
65 $this->getOutput()->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
66
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;
83 }
84 }
85
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' );
90
91 $authority = $this->getAuthority();
92 $userCanManage = $authority->isAllowed( 'managechangetags' );
93 $userCanDelete = $authority->isAllowed( 'deletechangetags' );
94 $userCanEditInterface = $authority->isAllowed( 'editinterface' );
95
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,
103 ],
104 'Reason' => [
105 'type' => 'text',
106 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
107 'label' => $this->msg( 'tags-create-reason' )->plain(),
108 'size' => 50,
109 ],
110 'IgnoreWarnings' => [
111 'type' => 'hidden',
112 ],
113 ];
114
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();
122
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;
132 }
133 }
134
135 // Used to get hitcounts for #doTagRow()
136 $tagStats = $this->changeTagsStore->tagUsageStatistics();
137
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 );
143
144 // List all defined tags, even if they were never applied
145 $definedTags = array_keys( $this->explicitlyDefinedTags + $this->softwareDefinedTags );
146
147 // Show header only if there exists at least one tag
148 if ( !$tagStats && !$definedTags ) {
149 return;
150 }
151
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 '' )
163 );
164
165 $tbody = '';
166 // Used in #doTagRow()
167 $this->softwareActivatedTags = array_fill_keys(
168 $this->changeTagsStore->listSoftwareActivatedTags(), true );
169
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 );
174 }
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 );
179 }
180 }
181
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 ) );
193 }
194
195 private function doTagRow(
196 $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks
197 ) {
198 $newRow = '';
199 $newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, $tag ) );
200
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() );
205 }
206 if ( $showEditLinks ) {
207 $disp .= ' ';
208 $editLink = $linkRenderer->makeLink(
209 $this->msg( "tag-$tag" )->getTitle(),
210 $this->msg( 'tags-edit' )->text(),
211 [],
212 [ 'action' => 'edit' ]
213 );
214 $helpEditLink = $linkRenderer->makeLink(
215 $this->msg( "tag-$tag-helppage" )->inContentLanguage()->getTitle(),
216 $this->msg( 'tags-helppage-edit' )->text(),
217 [],
218 [ 'action' => 'edit' ]
219 );
220 $disp .= $this->msg( 'parentheses' )->rawParams(
221 $this->getLanguage()->pipeList( [ $editLink, $helpEditLink ] )
222 )->escaped();
223 }
224 $newRow .= Xml::tags( 'td', null, $disp );
225
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(),
233 [],
234 [ 'action' => 'edit' ]
235 );
236 $desc .= $this->msg( 'parentheses' )->rawParams( $editDescLink )->escaped();
237 }
238 $newRow .= Xml::tags( 'td', null, $desc );
239
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();
246 }
247 if ( $isExplicit ) {
248 $sourceMsgs[] = $this->msg( 'tags-source-manual' )->escaped();
249 }
250 if ( !$sourceMsgs ) {
251 $sourceMsgs[] = $this->msg( 'tags-source-none' )->escaped();
252 }
253 $newRow .= Xml::tags( 'td', null, implode( Xml::element( 'br' ), $sourceMsgs ) );
254
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() );
258
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(),
264 [],
265 [ 'tagfilter' => $tag ]
266 );
267 } else {
268 $hitcountLabel = $hitcountLabelMsg->escaped();
269 }
270
271 // add raw $hitcount for sorting, because tags-hitcount contains numbers and letters
272 $newRow .= Xml::tags( 'td', [ 'data-sort-value' => $hitcount ], $hitcountLabel );
273
274 $actionLinks = [];
275
276 if ( $showDeleteActions && ChangeTags::canDeleteTag( $tag )->isOK() ) {
277 $actionLinks[] = $linkRenderer->makeKnownLink(
278 $this->getPageTitle( 'delete' ),
279 $this->msg( 'tags-delete' )->text(),
280 [],
281 [ 'tag' => $tag ] );
282 }
283
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(),
289 [],
290 [ 'tag' => $tag ] );
291 }
292
293 if ( ChangeTags::canDeactivateTag( $tag )->isOK() ) {
294 $actionLinks[] = $linkRenderer->makeKnownLink(
295 $this->getPageTitle( 'deactivate' ),
296 $this->msg( 'tags-deactivate' )->text(),
297 [],
298 [ 'tag' => $tag ] );
299 }
300 }
301
302 if ( $showDeleteActions || $showManageActions ) {
303 $newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) );
304 }
305
306 return Xml::tags( 'tr', null, $newRow ) . "\n";
307 }
308
309 public function processCreateTagForm( array $data, HTMLForm $form ) {
310 $context = $form->getContext();
311 $out = $context->getOutput();
312
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 );
317
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',
328 ],
329 ] );
330
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();
335
336 $form->setHeaderHtml( $headerText )
337 ->setSubmitTextMsg( 'htmlform-yes' );
338
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 ) );
346 }
347 return false;
348 }
349 }
350
351 protected function showDeleteTagForm( $tag ) {
352 $authority = $this->getAuthority();
353 if ( !$authority->isAllowed( 'deletechangetags' ) ) {
354 throw new PermissionsError( 'deletechangetags' );
355 }
356
357 $out = $this->getOutput();
358 $out->setPageTitleMsg( $this->msg( 'tags-delete-title' ) );
359 $out->addBacklinkSubtitle( $this->getPageTitle() );
360
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 ) );
368 }
369 if ( !$canDeleteResult->isOK() ) {
370 return;
371 }
372 }
373
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();
379 }
380 $preText .= $this->msg( 'tags-delete-explanation-warning', $tag )->parseAsBlock();
381
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();
387 }
388
389 $fields = [];
390 $fields['Reason'] = [
391 'type' => 'text',
392 'label' => $this->msg( 'tags-delete-reason' )->plain(),
393 'size' => 50,
394 ];
395 $fields['HiddenTag'] = [
396 'type' => 'hidden',
397 'name' => 'tag',
398 'default' => $tag,
399 'required' => true,
400 ];
401
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' );
406 } )
407 ->setSubmitTextMsg( 'tags-delete-submit' )
408 ->setSubmitDestructive()
409 ->addPreHtml( $preText )
410 ->show();
411 }
412
413 protected function showActivateDeactivateForm( $tag, $activate ) {
414 $actionStr = $activate ? 'activate' : 'deactivate';
415
416 $authority = $this->getAuthority();
417 if ( !$authority->isAllowed( 'managechangetags' ) ) {
418 throw new PermissionsError( 'managechangetags' );
419 }
420
421 $out = $this->getOutput();
422 // tags-activate-title, tags-deactivate-title
423 $out->setPageTitleMsg( $this->msg( "tags-$actionStr-title" ) );
424 $out->addBacklinkSubtitle( $this->getPageTitle() );
425
426 // is it possible to do this?
427 if ( $activate ) {
428 $result = ChangeTags::canActivateTag( $tag, $authority );
429 } else {
430 $result = ChangeTags::canDeactivateTag( $tag, $authority );
431 }
432 if ( !$result->isGood() ) {
433 foreach ( $result->getMessages() as $msg ) {
434 $out->addHTML( Html::errorBox(
435 $this->msg( $msg )->parse()
436 ) );
437 }
438 if ( !$result->isOK() ) {
439 return;
440 }
441 }
442
443 // tags-activate-question, tags-deactivate-question
444 $preText = $this->msg( "tags-$actionStr-question", $tag )->parseAsBlock();
445
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,
452 ];
453 $fields['HiddenTag'] = [
454 'type' => 'hidden',
455 'name' => 'tag',
456 'default' => $tag,
457 'required' => true,
458 ];
459
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 );
464 } )
465 // tags-activate-submit, tags-deactivate-submit
466 ->setSubmitTextMsg( "tags-$actionStr-submit" )
467 ->addPreHtml( $preText )
468 ->show();
469 }
470
477 public function processTagForm( array $data, HTMLForm $form, string $action ) {
478 $context = $form->getContext();
479 $out = $context->getOutput();
480
481 $tag = $data['HiddenTag'];
482 // activateTagWithChecks, deactivateTagWithChecks, deleteTagWithChecks
483 $status = call_user_func( [ ChangeTags::class, "{$action}TagWithChecks" ],
484 $tag, $data['Reason'], $context->getUser(), true );
485
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 ) );
501 }
502 return false;
503 }
504 }
505
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',
518 ];
519 }
520
521 protected function getGroupName() {
522 return 'changes';
523 }
524}
525
530class_alias( SpecialTags::class, 'SpecialTags' );
Recent changes tagging.
static tagDescription( $tag, MessageLocalizer $context)
Get a short description for a tag.
static canActivateTag( $tag, ?Authority $performer=null)
Is it OK to allow the user to activate this 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.
static canDeleteTag( $tag, ?Authority $performer=null, int $flags=0)
Is it OK to allow the user to delete this tag?
static canDeactivateTag( $tag, ?Authority $performer=null)
Is it OK to allow the user to deactivate this tag?
Read-write access to the 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:209
setHeaderHtml( $html, $section=null)
Set header HTML, inside the form.
Definition HTMLForm.php:997
addFields( $descriptor)
Add fields to the form.
Definition HTMLForm.php:496
This class is a collection of static functions that serve two purposes:
Definition Html.php:56
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition Html.php:216
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 By default the message key is the canonical name of...
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.
Module of static functions for generating XML.
Definition Xml.php:37
Show an error when a user tries to do something they do not have the necessary permissions for.