MediaWiki  master
SpecialTags.php
Go to the documentation of this file.
1 <?php
25 
31 class SpecialTags extends SpecialPage {
32 
37 
42 
47 
50 
55  parent::__construct( 'Tags' );
56  $this->permissionManager = $permissionManager;
57  }
58 
59  public function execute( $par ) {
60  $this->setHeaders();
61  $this->outputHeader();
62  $this->addHelpLink( 'Manual:Tags' );
63 
64  $request = $this->getRequest();
65  switch ( $par ) {
66  case 'delete':
67  $this->showDeleteTagForm( $request->getVal( 'tag' ) );
68  break;
69  case 'activate':
70  $this->showActivateDeactivateForm( $request->getVal( 'tag' ), true );
71  break;
72  case 'deactivate':
73  $this->showActivateDeactivateForm( $request->getVal( 'tag' ), false );
74  break;
75  case 'create':
76  // fall through, thanks to HTMLForm's logic
77  default:
78  $this->showTagList();
79  break;
80  }
81  }
82 
83  private function showTagList() {
84  $out = $this->getOutput();
85  $out->setPageTitle( $this->msg( 'tags-title' ) );
86  $out->wrapWikiMsg( "<div class='mw-tags-intro'>\n$1\n</div>", 'tags-intro' );
87 
88  $user = $this->getUser();
89  $userCanManage = $this->permissionManager->userHasRight( $user, 'managechangetags' );
90  $userCanDelete = $this->permissionManager->userHasRight( $user, 'deletechangetags' );
91  $userCanEditInterface = $this->permissionManager->userHasRight( $user, 'editinterface' );
92 
93  // Show form to create a tag
94  if ( $userCanManage ) {
95  $fields = [
96  'Tag' => [
97  'type' => 'text',
98  'label' => $this->msg( 'tags-create-tag-name' )->plain(),
99  'required' => true,
100  ],
101  'Reason' => [
102  'type' => 'text',
104  'label' => $this->msg( 'tags-create-reason' )->plain(),
105  'size' => 50,
106  ],
107  'IgnoreWarnings' => [
108  'type' => 'hidden',
109  ],
110  ];
111 
112  HTMLForm::factory( 'ooui', $fields, $this->getContext() )
113  ->setAction( $this->getPageTitle( 'create' )->getLocalURL() )
114  ->setWrapperLegendMsg( 'tags-create-heading' )
115  ->setHeaderText( $this->msg( 'tags-create-explanation' )->parseAsBlock() )
116  ->setSubmitCallback( [ $this, 'processCreateTagForm' ] )
117  ->setSubmitTextMsg( 'tags-create-submit' )
118  ->show();
119 
120  // If processCreateTagForm generated a redirect, there's no point
121  // continuing with this, as the user is just going to end up getting sent
122  // somewhere else. Additionally, if we keep going here, we end up
123  // populating the memcache of tag data (see ChangeTags::listDefinedTags)
124  // with out-of-date data from the replica DB, because the replica DB hasn't caught
125  // up to the fact that a new tag has been created as part of an implicit,
126  // as yet uncommitted transaction on master.
127  if ( $out->getRedirect() !== '' ) {
128  return;
129  }
130  }
131 
132  // Used to get hitcounts for #doTagRow()
133  $tagStats = ChangeTags::tagUsageStatistics();
134 
135  // Used in #doTagRow()
136  $this->explicitlyDefinedTags = array_fill_keys(
138  $this->softwareDefinedTags = array_fill_keys(
140 
141  // List all defined tags, even if they were never applied
142  $definedTags = array_keys( $this->explicitlyDefinedTags + $this->softwareDefinedTags );
143 
144  // Show header only if there exists atleast one tag
145  if ( !$tagStats && !$definedTags ) {
146  return;
147  }
148 
149  // Write the headers
150  $thead = Xml::tags( 'tr', null, Xml::tags( 'th', null, $this->msg( 'tags-tag' )->parse() ) .
151  Xml::tags( 'th', null, $this->msg( 'tags-display-header' )->parse() ) .
152  Xml::tags( 'th', null, $this->msg( 'tags-description-header' )->parse() ) .
153  Xml::tags( 'th', null, $this->msg( 'tags-source-header' )->parse() ) .
154  Xml::tags( 'th', null, $this->msg( 'tags-active-header' )->parse() ) .
155  Xml::tags( 'th', null, $this->msg( 'tags-hitcount-header' )->parse() ) .
156  ( ( $userCanManage || $userCanDelete ) ?
157  Xml::tags( 'th', [ 'class' => 'unsortable' ],
158  $this->msg( 'tags-actions-header' )->parse() ) :
159  '' )
160  );
161 
162  $tbody = '';
163  // Used in #doTagRow()
164  $this->softwareActivatedTags = array_fill_keys(
166 
167  // Insert tags that have been applied at least once
168  foreach ( $tagStats as $tag => $hitcount ) {
169  $tbody .= $this->doTagRow( $tag, $hitcount, $userCanManage,
170  $userCanDelete, $userCanEditInterface );
171  }
172  // Insert tags defined somewhere but never applied
173  foreach ( $definedTags as $tag ) {
174  if ( !isset( $tagStats[$tag] ) ) {
175  $tbody .= $this->doTagRow( $tag, 0, $userCanManage, $userCanDelete, $userCanEditInterface );
176  }
177  }
178 
179  $out->addModuleStyles( 'jquery.tablesorter.styles' );
180  $out->addModules( 'jquery.tablesorter' );
181  $out->addHTML( Xml::tags(
182  'table',
183  [ 'class' => 'mw-datatable sortable mw-tags-table' ],
184  Xml::tags( 'thead', null, $thead ) .
185  Xml::tags( 'tbody', null, $tbody )
186  ) );
187  }
188 
189  private function doTagRow(
190  $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks
191  ) {
192  $newRow = '';
193  $newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, $tag ) );
194 
195  $linkRenderer = $this->getLinkRenderer();
196  $disp = ChangeTags::tagDescription( $tag, $this->getContext() );
197  if ( $showEditLinks ) {
198  $disp .= ' ';
199  $editLink = $linkRenderer->makeLink(
200  $this->msg( "tag-$tag" )->inContentLanguage()->getTitle(),
201  $this->msg( 'tags-edit' )->text(),
202  [],
203  [ 'action' => 'edit' ]
204  );
205  $disp .= $this->msg( 'parentheses' )->rawParams( $editLink )->escaped();
206  }
207  $newRow .= Xml::tags( 'td', null, $disp );
208 
209  $msg = $this->msg( "tag-$tag-description" );
210  $desc = !$msg->exists() ? '' : $msg->parse();
211  if ( $showEditLinks ) {
212  $desc .= ' ';
213  $editDescLink = $linkRenderer->makeLink(
214  $this->msg( "tag-$tag-description" )->inContentLanguage()->getTitle(),
215  $this->msg( 'tags-edit' )->text(),
216  [],
217  [ 'action' => 'edit' ]
218  );
219  $desc .= $this->msg( 'parentheses' )->rawParams( $editDescLink )->escaped();
220  }
221  $newRow .= Xml::tags( 'td', null, $desc );
222 
223  $sourceMsgs = [];
224  $isSoftware = isset( $this->softwareDefinedTags[$tag] );
225  $isExplicit = isset( $this->explicitlyDefinedTags[$tag] );
226  if ( $isSoftware ) {
227  // TODO: Rename this message
228  $sourceMsgs[] = $this->msg( 'tags-source-extension' )->escaped();
229  }
230  if ( $isExplicit ) {
231  $sourceMsgs[] = $this->msg( 'tags-source-manual' )->escaped();
232  }
233  if ( !$sourceMsgs ) {
234  $sourceMsgs[] = $this->msg( 'tags-source-none' )->escaped();
235  }
236  $newRow .= Xml::tags( 'td', null, implode( Xml::element( 'br' ), $sourceMsgs ) );
237 
238  $isActive = $isExplicit || isset( $this->softwareActivatedTags[$tag] );
239  $activeMsg = ( $isActive ? 'tags-active-yes' : 'tags-active-no' );
240  $newRow .= Xml::tags( 'td', null, $this->msg( $activeMsg )->escaped() );
241 
242  $hitcountLabelMsg = $this->msg( 'tags-hitcount' )->numParams( $hitcount );
243  if ( $this->getConfig()->get( 'UseTagFilter' ) ) {
244  $hitcountLabel = $linkRenderer->makeLink(
245  SpecialPage::getTitleFor( 'Recentchanges' ),
246  $hitcountLabelMsg->text(),
247  [],
248  [ 'tagfilter' => $tag ]
249  );
250  } else {
251  $hitcountLabel = $hitcountLabelMsg->escaped();
252  }
253 
254  // add raw $hitcount for sorting, because tags-hitcount contains numbers and letters
255  $newRow .= Xml::tags( 'td', [ 'data-sort-value' => $hitcount ], $hitcountLabel );
256 
257  $actionLinks = [];
258 
259  if ( $showDeleteActions && ChangeTags::canDeleteTag( $tag )->isOK() ) {
260  $actionLinks[] = $linkRenderer->makeKnownLink(
261  $this->getPageTitle( 'delete' ),
262  $this->msg( 'tags-delete' )->text(),
263  [],
264  [ 'tag' => $tag ] );
265  }
266 
267  if ( $showManageActions ) { // we've already checked that the user had the requisite userright
268  if ( ChangeTags::canActivateTag( $tag )->isOK() ) {
269  $actionLinks[] = $linkRenderer->makeKnownLink(
270  $this->getPageTitle( 'activate' ),
271  $this->msg( 'tags-activate' )->text(),
272  [],
273  [ 'tag' => $tag ] );
274  }
275 
276  if ( ChangeTags::canDeactivateTag( $tag )->isOK() ) {
277  $actionLinks[] = $linkRenderer->makeKnownLink(
278  $this->getPageTitle( 'deactivate' ),
279  $this->msg( 'tags-deactivate' )->text(),
280  [],
281  [ 'tag' => $tag ] );
282  }
283  }
284 
285  if ( $showDeleteActions || $showManageActions ) {
286  $newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) );
287  }
288 
289  return Xml::tags( 'tr', null, $newRow ) . "\n";
290  }
291 
292  public function processCreateTagForm( array $data, HTMLForm $form ) {
293  $context = $form->getContext();
294  $out = $context->getOutput();
295 
296  $tag = trim( strval( $data['Tag'] ) );
297  $ignoreWarnings = isset( $data['IgnoreWarnings'] ) && $data['IgnoreWarnings'] === '1';
298  $status = ChangeTags::createTagWithChecks( $tag, $data['Reason'],
299  $context->getUser(), $ignoreWarnings );
300 
301  if ( $status->isGood() ) {
302  $out->redirect( $this->getPageTitle()->getLocalURL() );
303  return true;
304  } elseif ( $status->isOK() ) {
305  // we have some warnings, so we show a confirmation form
306  $fields = [
307  'Tag' => [
308  'type' => 'hidden',
309  'default' => $data['Tag'],
310  ],
311  'Reason' => [
312  'type' => 'hidden',
313  'default' => $data['Reason'],
314  ],
315  'IgnoreWarnings' => [
316  'type' => 'hidden',
317  'default' => '1',
318  ],
319  ];
320 
321  // fool HTMLForm into thinking the form hasn't been submitted yet. Otherwise
322  // we get into an infinite loop!
323  $context->getRequest()->unsetVal( 'wpEditToken' );
324 
325  $headerText = $this->msg( 'tags-create-warnings-above', $tag,
326  count( $status->getWarningsArray() ) )->parseAsBlock() .
327  $out->parseAsInterface( $status->getWikiText() ) .
328  $this->msg( 'tags-create-warnings-below' )->parseAsBlock();
329 
330  HTMLForm::factory( 'ooui', $fields, $this->getContext() )
331  ->setAction( $this->getPageTitle( 'create' )->getLocalURL() )
332  ->setWrapperLegendMsg( 'tags-create-heading' )
333  ->setHeaderText( $headerText )
334  ->setSubmitCallback( [ $this, 'processCreateTagForm' ] )
335  ->setSubmitTextMsg( 'htmlform-yes' )
336  ->show();
337 
338  $out->addBacklinkSubtitle( $this->getPageTitle() );
339  return true;
340  } else {
341  $out->wrapWikiTextAsInterface( 'error', $status->getWikiText() );
342  return false;
343  }
344  }
345 
346  protected function showDeleteTagForm( $tag ) {
347  $user = $this->getUser();
348  if ( !$this->permissionManager->userHasRight( $user, 'deletechangetags' ) ) {
349  throw new PermissionsError( 'deletechangetags' );
350  }
351 
352  $out = $this->getOutput();
353  $out->setPageTitle( $this->msg( 'tags-delete-title' ) );
354  $out->addBacklinkSubtitle( $this->getPageTitle() );
355 
356  // is the tag actually able to be deleted?
357  $canDeleteResult = ChangeTags::canDeleteTag( $tag, $user );
358  if ( !$canDeleteResult->isGood() ) {
359  $out->wrapWikiTextAsInterface( 'error', $canDeleteResult->getWikiText() );
360  if ( !$canDeleteResult->isOK() ) {
361  return;
362  }
363  }
364 
365  $preText = $this->msg( 'tags-delete-explanation-initial', $tag )->parseAsBlock();
366  $tagUsage = ChangeTags::tagUsageStatistics();
367  if ( isset( $tagUsage[$tag] ) && $tagUsage[$tag] > 0 ) {
368  $preText .= $this->msg( 'tags-delete-explanation-in-use', $tag,
369  $tagUsage[$tag] )->parseAsBlock();
370  }
371  $preText .= $this->msg( 'tags-delete-explanation-warning', $tag )->parseAsBlock();
372 
373  // see if the tag is in use
374  $this->softwareActivatedTags = array_fill_keys(
376  if ( isset( $this->softwareActivatedTags[$tag] ) ) {
377  $preText .= $this->msg( 'tags-delete-explanation-active', $tag )->parseAsBlock();
378  }
379 
380  $fields = [];
381  $fields['Reason'] = [
382  'type' => 'text',
383  'label' => $this->msg( 'tags-delete-reason' )->plain(),
384  'size' => 50,
385  ];
386  $fields['HiddenTag'] = [
387  'type' => 'hidden',
388  'name' => 'tag',
389  'default' => $tag,
390  'required' => true,
391  ];
392 
393  HTMLForm::factory( 'ooui', $fields, $this->getContext() )
394  ->setAction( $this->getPageTitle( 'delete' )->getLocalURL() )
395  ->setSubmitCallback( function ( $data, $form ) {
396  return $this->processTagForm( $data, $form, 'delete' );
397  } )
398  ->setSubmitTextMsg( 'tags-delete-submit' )
399  ->setSubmitDestructive()
400  ->addPreText( $preText )
401  ->show();
402  }
403 
404  protected function showActivateDeactivateForm( $tag, $activate ) {
405  $actionStr = $activate ? 'activate' : 'deactivate';
406 
407  $user = $this->getUser();
408  if ( !$this->permissionManager->userHasRight( $user, 'managechangetags' ) ) {
409  throw new PermissionsError( 'managechangetags' );
410  }
411 
412  $out = $this->getOutput();
413  // tags-activate-title, tags-deactivate-title
414  $out->setPageTitle( $this->msg( "tags-$actionStr-title" ) );
415  $out->addBacklinkSubtitle( $this->getPageTitle() );
416 
417  // is it possible to do this?
418  $func = $activate ? 'canActivateTag' : 'canDeactivateTag';
419  $result = ChangeTags::$func( $tag, $user );
420  if ( !$result->isGood() ) {
421  $out->wrapWikiTextAsInterface( 'error', $result->getWikiText() );
422  if ( !$result->isOK() ) {
423  return;
424  }
425  }
426 
427  // tags-activate-question, tags-deactivate-question
428  $preText = $this->msg( "tags-$actionStr-question", $tag )->parseAsBlock();
429 
430  $fields = [];
431  // tags-activate-reason, tags-deactivate-reason
432  $fields['Reason'] = [
433  'type' => 'text',
434  'label' => $this->msg( "tags-$actionStr-reason" )->plain(),
435  'size' => 50,
436  ];
437  $fields['HiddenTag'] = [
438  'type' => 'hidden',
439  'name' => 'tag',
440  'default' => $tag,
441  'required' => true,
442  ];
443 
444  HTMLForm::factory( 'ooui', $fields, $this->getContext() )
445  ->setAction( $this->getPageTitle( $actionStr )->getLocalURL() )
446  ->setSubmitCallback( function ( $data, $form ) use ( $actionStr ) {
447  return $this->processTagForm( $data, $form, $actionStr );
448  } )
449  // tags-activate-submit, tags-deactivate-submit
450  ->setSubmitTextMsg( "tags-$actionStr-submit" )
451  ->addPreText( $preText )
452  ->show();
453  }
454 
461  public function processTagForm( array $data, HTMLForm $form, string $action ) {
462  $context = $form->getContext();
463  $out = $context->getOutput();
464 
465  $tag = $data['HiddenTag'];
466  // activateTagWithChecks, deactivateTagWithChecks, deleteTagWithChecks
467  $status = call_user_func( [ ChangeTags::class, "{$action}TagWithChecks" ],
468  $tag, $data['Reason'], $context->getUser(), true );
469 
470  if ( $status->isGood() ) {
471  $out->redirect( $this->getPageTitle()->getLocalURL() );
472  return true;
473  } elseif ( $status->isOK() && $action === 'delete' ) {
474  // deletion succeeded, but hooks raised a warning
475  $out->addWikiTextAsInterface( $this->msg( 'tags-delete-warnings-after-delete', $tag,
476  count( $status->getWarningsArray() ) )->text() . "\n" .
477  $status->getWikitext() );
478  $out->addReturnTo( $this->getPageTitle() );
479  return true;
480  } else {
481  $out->wrapWikiTextAsInterface( 'error', $status->getWikitext() );
482  return false;
483  }
484  }
485 
491  public function getSubpagesForPrefixSearch() {
492  // The subpages does not have an own form, so not listing it at the moment
493  return [
494  // 'delete',
495  // 'activate',
496  // 'deactivate',
497  // 'create',
498  ];
499  }
500 
501  protected function getGroupName() {
502  return 'changes';
503  }
504 }
SpecialPage\$linkRenderer
LinkRenderer null $linkRenderer
Definition: SpecialPage.php:79
SpecialTags
A special page that lists tags for edits.
Definition: SpecialTags.php:31
SpecialPage\getPageTitle
getPageTitle( $subpage=false)
Get a self-referential title object.
Definition: SpecialPage.php:742
SpecialPage\msg
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
Definition: SpecialPage.php:900
ContextSource\getContext
getContext()
Get the base IContextSource object.
Definition: ContextSource.php:46
SpecialPage\getOutput
getOutput()
Get the OutputPage being used for this instance.
Definition: SpecialPage.php:788
SpecialPage\getTitleFor
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,...
Definition: SpecialPage.php:106
SpecialTags\processCreateTagForm
processCreateTagForm(array $data, HTMLForm $form)
Definition: SpecialTags.php:292
PermissionsError
Show an error when a user tries to do something they do not have the necessary permissions for.
Definition: PermissionsError.php:31
SpecialPage\getLanguage
getLanguage()
Shortcut to get user's language.
Definition: SpecialPage.php:818
SpecialTags\getSubpagesForPrefixSearch
getSubpagesForPrefixSearch()
Return an array of subpages that this special page will accept.
Definition: SpecialTags.php:491
ChangeTags\listSoftwareDefinedTags
static listSoftwareDefinedTags()
Lists tags defined by core or extensions using the ListDefinedTags hook.
Definition: ChangeTags.php:1588
SpecialPage\addHelpLink
addHelpLink( $to, $overrideBaseUrl=false)
Adds help link with an icon via page indicators.
Definition: SpecialPage.php:936
SpecialPage\getConfig
getConfig()
Shortcut to get main config object.
Definition: SpecialPage.php:866
SpecialTags\$explicitlyDefinedTags
array $explicitlyDefinedTags
List of explicitly defined tags.
Definition: SpecialTags.php:36
SpecialTags\getGroupName
getGroupName()
Under which header this special page is listed in Special:SpecialPages See messages 'specialpages-gro...
Definition: SpecialTags.php:501
Xml\element
static element( $element, $attribs=null, $contents='', $allowShortTag=true)
Format an XML element with given attributes and, optionally, text content.
Definition: Xml.php:41
SpecialTags\$permissionManager
PermissionManager $permissionManager
Definition: SpecialTags.php:49
SpecialPage\setHeaders
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!...
Definition: SpecialPage.php:616
SpecialPage\getUser
getUser()
Shortcut to get the User executing this instance.
Definition: SpecialPage.php:798
ChangeTags\canActivateTag
static canActivateTag( $tag, User $user=null)
Is it OK to allow the user to activate this tag?
Definition: ChangeTags.php:1120
SpecialPage\getContext
getContext()
Gets the context this SpecialPage is executed in.
Definition: SpecialPage.php:762
ChangeTags\listExplicitlyDefinedTags
static listExplicitlyDefinedTags()
Lists tags explicitly defined in the change_tag_def table of the database.
Definition: ChangeTags.php:1550
MediaWiki\Permissions\PermissionManager
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Definition: PermissionManager.php:51
SpecialTags\showTagList
showTagList()
Definition: SpecialTags.php:83
SpecialPage
Parent class for all special pages.
Definition: SpecialPage.php:42
SpecialTags\showActivateDeactivateForm
showActivateDeactivateForm( $tag, $activate)
Definition: SpecialTags.php:404
SpecialTags\doTagRow
doTagRow( $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks)
Definition: SpecialTags.php:189
Xml\tags
static tags( $element, $attribs, $contents)
Same as Xml::element(), but does not escape contents.
Definition: Xml.php:132
ChangeTags\canDeleteTag
static canDeleteTag( $tag, User $user=null, int $flags=0)
Is it OK to allow the user to delete this tag?
Definition: ChangeTags.php:1414
SpecialPage\getRequest
getRequest()
Get the WebRequest being used for this instance.
Definition: SpecialPage.php:778
ChangeTags\listSoftwareActivatedTags
static listSoftwareActivatedTags()
Lists those tags which core or extensions report as being "active".
Definition: ChangeTags.php:1503
CommentStore\COMMENT_CHARACTER_LIMIT
const COMMENT_CHARACTER_LIMIT
Maximum length of a comment in UTF-8 characters.
Definition: CommentStore.php:48
SpecialPage\getLinkRenderer
getLinkRenderer()
Definition: SpecialPage.php:1016
SpecialTags\execute
execute( $par)
Default execute method Checks user permissions.
Definition: SpecialTags.php:59
SpecialTags\$softwareDefinedTags
array $softwareDefinedTags
List of software defined tags.
Definition: SpecialTags.php:41
getTitle
getTitle()
Definition: RevisionSearchResultTrait.php:81
SpecialTags\$softwareActivatedTags
array $softwareActivatedTags
List of software activated tags.
Definition: SpecialTags.php:46
SpecialTags\showDeleteTagForm
showDeleteTagForm( $tag)
Definition: SpecialTags.php:346
ChangeTags\tagUsageStatistics
static tagUsageStatistics()
Returns a map of any tags used on the wiki to number of edits tagged with them, ordered descending by...
Definition: ChangeTags.php:1636
SpecialTags\__construct
__construct(PermissionManager $permissionManager)
Definition: SpecialTags.php:54
ChangeTags\createTagWithChecks
static createTagWithChecks( $tag, $reason, User $user, $ignoreWarnings=false, array $logEntryTags=[])
Creates a tag by adding it to change_tag_def table.
Definition: ChangeTags.php:1340
SpecialTags\processTagForm
processTagForm(array $data, HTMLForm $form, string $action)
Definition: SpecialTags.php:461
HTMLForm\factory
static factory( $displayFormat,... $arguments)
Construct a HTMLForm object for given display type.
Definition: HTMLForm.php:321
SpecialPage\outputHeader
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages Per default the message key is the canonical name o...
Definition: SpecialPage.php:707
ChangeTags\tagDescription
static tagDescription( $tag, MessageLocalizer $context)
Get a short description for a tag.
Definition: ChangeTags.php:263
ChangeTags\canDeactivateTag
static canDeactivateTag( $tag, User $user=null)
Is it OK to allow the user to deactivate this tag?
Definition: ChangeTags.php:1194
HTMLForm
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition: HTMLForm.php:140