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