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