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