MediaWiki  master
SpecialTags.php
Go to the documentation of this file.
1 <?php
24 namespace MediaWiki\Specials;
25 
26 use ChangeTags;
27 use HTMLForm;
33 use Xml;
34 
40 class 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',
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 
507 class_alias( SpecialTags::class, 'SpecialTags' );
static canDeactivateTag( $tag, Authority $performer=null)
Is it OK to allow the user to deactivate this tag?
Definition: ChangeTags.php:834
static canActivateTag( $tag, Authority $performer=null)
Is it OK to allow the user to activate this tag?
Definition: ChangeTags.php:759
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.
Definition: ChangeTags.php:237
static createTagWithChecks(string $tag, string $reason, Authority $performer, bool $ignoreWarnings=false, array $logEntryTags=[])
Creates a tag by adding it to change_tag_def table.
Definition: ChangeTags.php:990
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:158
setHeaderHtml( $html, $section=null)
Set header HTML, inside the form.
Definition: HTMLForm.php:922
static factory( $displayFormat, $descriptor, IContextSource $context, $messagePrefix='')
Construct a HTMLForm object for given display type.
Definition: HTMLForm.php:360
addFields( $descriptor)
Add fields to the form.
Definition: HTMLForm.php:414
Gateway class for change_tags table.
Handle database storage of comments such as edit summaries and log reasons.
const COMMENT_CHARACTER_LIMIT
Maximum length of a comment in UTF-8 characters.
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.
Definition: SpecialPage.php:66
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.
Definition: SpecialTags.php:40
execute( $par)
Default execute method Checks user permissions.
Definition: SpecialTags.php:63
processTagForm(array $data, HTMLForm $form, string $action)
array $softwareActivatedTags
List of software activated tags.
Definition: SpecialTags.php:55
processCreateTagForm(array $data, HTMLForm $form)
__construct(ChangeTagsStore $changeTagsStore)
Definition: SpecialTags.php:58
array $explicitlyDefinedTags
List of explicitly defined tags.
Definition: SpecialTags.php:45
showActivateDeactivateForm( $tag, $activate)
array $softwareDefinedTags
List of software defined tags.
Definition: SpecialTags.php:50
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.
getTitle()
Get the Title object that we'll be acting on, as specified in the WebRequest.
Definition: MediaWiki.php:186
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