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