includes/specials/SpecialTags.php
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\Specials;
use ChangeTags;
use MediaWiki\ChangeTags\ChangeTagsStore;
use MediaWiki\CommentStore\CommentStore;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\MainConfigNames;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Xml\Xml;
use PermissionsError;
/**
* A special page that lists tags for edits
*
* @ingroup SpecialPage
*/
class SpecialTags extends SpecialPage {
/**
* @var array List of explicitly defined tags
*/
protected $explicitlyDefinedTags;
/**
* @var array List of software defined tags
*/
protected $softwareDefinedTags;
/**
* @var array List of software activated tags
*/
protected $softwareActivatedTags;
private ChangeTagsStore $changeTagsStore;
public function __construct( ChangeTagsStore $changeTagsStore ) {
parent::__construct( 'Tags' );
$this->changeTagsStore = $changeTagsStore;
}
public function execute( $par ) {
$this->setHeaders();
$this->outputHeader();
$this->addHelpLink( 'Manual:Tags' );
$request = $this->getRequest();
switch ( $par ) {
case 'delete':
$this->showDeleteTagForm( $request->getVal( 'tag' ) );
break;
case 'activate':
$this->showActivateDeactivateForm( $request->getVal( 'tag' ), true );
break;
case 'deactivate':
$this->showActivateDeactivateForm( $request->getVal( 'tag' ), false );
break;
case 'create':
// fall through, thanks to HTMLForm's logic
default:
$this->showTagList();
break;
}
}
private function showTagList() {
$out = $this->getOutput();
$out->setPageTitleMsg( $this->msg( 'tags-title' ) );
$out->wrapWikiMsg( "<div class='mw-tags-intro'>\n$1\n</div>", 'tags-intro' );
$authority = $this->getAuthority();
$userCanManage = $authority->isAllowed( 'managechangetags' );
$userCanDelete = $authority->isAllowed( 'deletechangetags' );
$userCanEditInterface = $authority->isAllowed( 'editinterface' );
// Show form to create a tag
if ( $userCanManage ) {
$fields = [
'Tag' => [
'type' => 'text',
'label' => $this->msg( 'tags-create-tag-name' )->plain(),
'required' => true,
],
'Reason' => [
'type' => 'text',
'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
'label' => $this->msg( 'tags-create-reason' )->plain(),
'size' => 50,
],
'IgnoreWarnings' => [
'type' => 'hidden',
],
];
HTMLForm::factory( 'ooui', $fields, $this->getContext() )
->setAction( $this->getPageTitle( 'create' )->getLocalURL() )
->setWrapperLegendMsg( 'tags-create-heading' )
->setHeaderHtml( $this->msg( 'tags-create-explanation' )->parseAsBlock() )
->setSubmitCallback( [ $this, 'processCreateTagForm' ] )
->setSubmitTextMsg( 'tags-create-submit' )
->show();
// If processCreateTagForm generated a redirect, there's no point
// continuing with this, as the user is just going to end up getting sent
// somewhere else. Additionally, if we keep going here, we end up
// populating the memcache of tag data (see ChangeTags::listDefinedTags)
// with out-of-date data from the replica DB, because the replica DB hasn't caught
// up to the fact that a new tag has been created as part of an implicit,
// as yet uncommitted transaction on primary DB.
if ( $out->getRedirect() !== '' ) {
return;
}
}
// Used to get hitcounts for #doTagRow()
$tagStats = $this->changeTagsStore->tagUsageStatistics();
// Used in #doTagRow()
$this->explicitlyDefinedTags = array_fill_keys(
$this->changeTagsStore->listExplicitlyDefinedTags(), true );
$this->softwareDefinedTags = array_fill_keys(
$this->changeTagsStore->listSoftwareDefinedTags(), true );
// List all defined tags, even if they were never applied
$definedTags = array_keys( $this->explicitlyDefinedTags + $this->softwareDefinedTags );
// Show header only if there exists at least one tag
if ( !$tagStats && !$definedTags ) {
return;
}
// Write the headers
$thead = Xml::tags( 'tr', null, Xml::tags( 'th', null, $this->msg( 'tags-tag' )->parse() ) .
Xml::tags( 'th', null, $this->msg( 'tags-display-header' )->parse() ) .
Xml::tags( 'th', null, $this->msg( 'tags-description-header' )->parse() ) .
Xml::tags( 'th', null, $this->msg( 'tags-source-header' )->parse() ) .
Xml::tags( 'th', null, $this->msg( 'tags-active-header' )->parse() ) .
Xml::tags( 'th', null, $this->msg( 'tags-hitcount-header' )->parse() ) .
( ( $userCanManage || $userCanDelete ) ?
Xml::tags( 'th', [ 'class' => 'unsortable' ],
$this->msg( 'tags-actions-header' )->parse() ) :
'' )
);
$tbody = '';
// Used in #doTagRow()
$this->softwareActivatedTags = array_fill_keys(
$this->changeTagsStore->listSoftwareActivatedTags(), true );
// Insert tags that have been applied at least once
foreach ( $tagStats as $tag => $hitcount ) {
$tbody .= $this->doTagRow( $tag, $hitcount, $userCanManage,
$userCanDelete, $userCanEditInterface );
}
// Insert tags defined somewhere but never applied
foreach ( $definedTags as $tag ) {
if ( !isset( $tagStats[$tag] ) ) {
$tbody .= $this->doTagRow( $tag, 0, $userCanManage, $userCanDelete, $userCanEditInterface );
}
}
$out->addModuleStyles( [
'jquery.tablesorter.styles',
'mediawiki.pager.styles'
] );
$out->addModules( 'jquery.tablesorter' );
$out->addHTML( Xml::tags(
'table',
[ 'class' => 'mw-datatable sortable mw-tags-table' ],
Xml::tags( 'thead', null, $thead ) .
Xml::tags( 'tbody', null, $tbody )
) );
}
private function doTagRow(
$tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks
) {
$newRow = '';
$newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, $tag ) );
$linkRenderer = $this->getLinkRenderer();
$disp = ChangeTags::tagDescription( $tag, $this->getContext() );
if ( $disp === false ) {
$disp = Xml::element( 'em', null, $this->msg( 'tags-hidden' )->text() );
}
if ( $showEditLinks ) {
$disp .= ' ';
$editLink = $linkRenderer->makeLink(
$this->msg( "tag-$tag" )->inContentLanguage()->getTitle(),
$this->msg( 'tags-edit' )->text(),
[],
[ 'action' => 'edit' ]
);
$disp .= $this->msg( 'parentheses' )->rawParams( $editLink )->escaped();
}
$newRow .= Xml::tags( 'td', null, $disp );
$msg = $this->msg( "tag-$tag-description" );
$desc = !$msg->exists() ? '' : $msg->parse();
if ( $showEditLinks ) {
$desc .= ' ';
$editDescLink = $linkRenderer->makeLink(
$this->msg( "tag-$tag-description" )->inContentLanguage()->getTitle(),
$this->msg( 'tags-edit' )->text(),
[],
[ 'action' => 'edit' ]
);
$desc .= $this->msg( 'parentheses' )->rawParams( $editDescLink )->escaped();
}
$newRow .= Xml::tags( 'td', null, $desc );
$sourceMsgs = [];
$isSoftware = isset( $this->softwareDefinedTags[$tag] );
$isExplicit = isset( $this->explicitlyDefinedTags[$tag] );
if ( $isSoftware ) {
// TODO: Rename this message
$sourceMsgs[] = $this->msg( 'tags-source-extension' )->escaped();
}
if ( $isExplicit ) {
$sourceMsgs[] = $this->msg( 'tags-source-manual' )->escaped();
}
if ( !$sourceMsgs ) {
$sourceMsgs[] = $this->msg( 'tags-source-none' )->escaped();
}
$newRow .= Xml::tags( 'td', null, implode( Xml::element( 'br' ), $sourceMsgs ) );
$isActive = $isExplicit || isset( $this->softwareActivatedTags[$tag] );
$activeMsg = ( $isActive ? 'tags-active-yes' : 'tags-active-no' );
$newRow .= Xml::tags( 'td', null, $this->msg( $activeMsg )->escaped() );
$hitcountLabelMsg = $this->msg( 'tags-hitcount' )->numParams( $hitcount );
if ( $this->getConfig()->get( MainConfigNames::UseTagFilter ) ) {
$hitcountLabel = $linkRenderer->makeLink(
SpecialPage::getTitleFor( 'Recentchanges' ),
$hitcountLabelMsg->text(),
[],
[ 'tagfilter' => $tag ]
);
} else {
$hitcountLabel = $hitcountLabelMsg->escaped();
}
// add raw $hitcount for sorting, because tags-hitcount contains numbers and letters
$newRow .= Xml::tags( 'td', [ 'data-sort-value' => $hitcount ], $hitcountLabel );
$actionLinks = [];
if ( $showDeleteActions && ChangeTags::canDeleteTag( $tag )->isOK() ) {
$actionLinks[] = $linkRenderer->makeKnownLink(
$this->getPageTitle( 'delete' ),
$this->msg( 'tags-delete' )->text(),
[],
[ 'tag' => $tag ] );
}
if ( $showManageActions ) { // we've already checked that the user had the requisite userright
if ( ChangeTags::canActivateTag( $tag )->isOK() ) {
$actionLinks[] = $linkRenderer->makeKnownLink(
$this->getPageTitle( 'activate' ),
$this->msg( 'tags-activate' )->text(),
[],
[ 'tag' => $tag ] );
}
if ( ChangeTags::canDeactivateTag( $tag )->isOK() ) {
$actionLinks[] = $linkRenderer->makeKnownLink(
$this->getPageTitle( 'deactivate' ),
$this->msg( 'tags-deactivate' )->text(),
[],
[ 'tag' => $tag ] );
}
}
if ( $showDeleteActions || $showManageActions ) {
$newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) );
}
return Xml::tags( 'tr', null, $newRow ) . "\n";
}
public function processCreateTagForm( array $data, HTMLForm $form ) {
$context = $form->getContext();
$out = $context->getOutput();
$tag = trim( strval( $data['Tag'] ) );
$ignoreWarnings = isset( $data['IgnoreWarnings'] ) && $data['IgnoreWarnings'] === '1';
$status = ChangeTags::createTagWithChecks( $tag, $data['Reason'],
$context->getAuthority(), $ignoreWarnings );
if ( $status->isGood() ) {
$out->redirect( $this->getPageTitle()->getLocalURL() );
return true;
} elseif ( $status->isOK() ) {
// We have some warnings, so we adjust the form for confirmation.
// This would override the existing field and its default value.
$form->addFields( [
'IgnoreWarnings' => [
'type' => 'hidden',
'default' => '1',
],
] );
$headerText = $this->msg( 'tags-create-warnings-above', $tag,
count( $status->getWarningsArray() ) )->parseAsBlock() .
$out->parseAsInterface( $status->getWikiText() ) .
$this->msg( 'tags-create-warnings-below' )->parseAsBlock();
$form->setHeaderHtml( $headerText )
->setSubmitTextMsg( 'htmlform-yes' );
$out->addBacklinkSubtitle( $this->getPageTitle() );
return false;
} else {
$out->wrapWikiTextAsInterface( 'error', $status->getWikiText() );
return false;
}
}
protected function showDeleteTagForm( $tag ) {
$authority = $this->getAuthority();
if ( !$authority->isAllowed( 'deletechangetags' ) ) {
throw new PermissionsError( 'deletechangetags' );
}
$out = $this->getOutput();
$out->setPageTitleMsg( $this->msg( 'tags-delete-title' ) );
$out->addBacklinkSubtitle( $this->getPageTitle() );
// is the tag actually able to be deleted?
$canDeleteResult = ChangeTags::canDeleteTag( $tag, $authority );
if ( !$canDeleteResult->isGood() ) {
$out->wrapWikiTextAsInterface( 'error', $canDeleteResult->getWikiText() );
if ( !$canDeleteResult->isOK() ) {
return;
}
}
$preText = $this->msg( 'tags-delete-explanation-initial', $tag )->parseAsBlock();
$tagUsage = $this->changeTagsStore->tagUsageStatistics();
if ( isset( $tagUsage[$tag] ) && $tagUsage[$tag] > 0 ) {
$preText .= $this->msg( 'tags-delete-explanation-in-use', $tag,
$tagUsage[$tag] )->parseAsBlock();
}
$preText .= $this->msg( 'tags-delete-explanation-warning', $tag )->parseAsBlock();
// see if the tag is in use
$this->softwareActivatedTags = array_fill_keys(
$this->changeTagsStore->listSoftwareActivatedTags(), true );
if ( isset( $this->softwareActivatedTags[$tag] ) ) {
$preText .= $this->msg( 'tags-delete-explanation-active', $tag )->parseAsBlock();
}
$fields = [];
$fields['Reason'] = [
'type' => 'text',
'label' => $this->msg( 'tags-delete-reason' )->plain(),
'size' => 50,
];
$fields['HiddenTag'] = [
'type' => 'hidden',
'name' => 'tag',
'default' => $tag,
'required' => true,
];
HTMLForm::factory( 'ooui', $fields, $this->getContext() )
->setAction( $this->getPageTitle( 'delete' )->getLocalURL() )
->setSubmitCallback( function ( $data, $form ) {
return $this->processTagForm( $data, $form, 'delete' );
} )
->setSubmitTextMsg( 'tags-delete-submit' )
->setSubmitDestructive()
->addPreHtml( $preText )
->show();
}
protected function showActivateDeactivateForm( $tag, $activate ) {
$actionStr = $activate ? 'activate' : 'deactivate';
$authority = $this->getAuthority();
if ( !$authority->isAllowed( 'managechangetags' ) ) {
throw new PermissionsError( 'managechangetags' );
}
$out = $this->getOutput();
// tags-activate-title, tags-deactivate-title
$out->setPageTitleMsg( $this->msg( "tags-$actionStr-title" ) );
$out->addBacklinkSubtitle( $this->getPageTitle() );
// is it possible to do this?
if ( $activate ) {
$result = ChangeTags::canActivateTag( $tag, $authority );
} else {
$result = ChangeTags::canDeactivateTag( $tag, $authority );
}
if ( !$result->isGood() ) {
$out->wrapWikiTextAsInterface( 'error', $result->getWikiText() );
if ( !$result->isOK() ) {
return;
}
}
// tags-activate-question, tags-deactivate-question
$preText = $this->msg( "tags-$actionStr-question", $tag )->parseAsBlock();
$fields = [];
// tags-activate-reason, tags-deactivate-reason
$fields['Reason'] = [
'type' => 'text',
'label' => $this->msg( "tags-$actionStr-reason" )->plain(),
'size' => 50,
];
$fields['HiddenTag'] = [
'type' => 'hidden',
'name' => 'tag',
'default' => $tag,
'required' => true,
];
HTMLForm::factory( 'ooui', $fields, $this->getContext() )
->setAction( $this->getPageTitle( $actionStr )->getLocalURL() )
->setSubmitCallback( function ( $data, $form ) use ( $actionStr ) {
return $this->processTagForm( $data, $form, $actionStr );
} )
// tags-activate-submit, tags-deactivate-submit
->setSubmitTextMsg( "tags-$actionStr-submit" )
->addPreHtml( $preText )
->show();
}
/**
* @param array $data
* @param HTMLForm $form
* @param string $action
* @return bool
*/
public function processTagForm( array $data, HTMLForm $form, string $action ) {
$context = $form->getContext();
$out = $context->getOutput();
$tag = $data['HiddenTag'];
// activateTagWithChecks, deactivateTagWithChecks, deleteTagWithChecks
$status = call_user_func( [ ChangeTags::class, "{$action}TagWithChecks" ],
$tag, $data['Reason'], $context->getUser(), true );
if ( $status->isGood() ) {
$out->redirect( $this->getPageTitle()->getLocalURL() );
return true;
} elseif ( $status->isOK() && $action === 'delete' ) {
// deletion succeeded, but hooks raised a warning
$out->addWikiTextAsInterface( $this->msg( 'tags-delete-warnings-after-delete', $tag,
count( $status->getWarningsArray() ) )->text() . "\n" .
$status->getWikitext() );
$out->addReturnTo( $this->getPageTitle() );
return true;
} else {
$out->wrapWikiTextAsInterface( 'error', $status->getWikitext() );
return false;
}
}
/**
* Return an array of subpages that this special page will accept.
*
* @return string[] subpages
*/
public function getSubpagesForPrefixSearch() {
// The subpages does not have an own form, so not listing it at the moment
return [
// 'delete',
// 'activate',
// 'deactivate',
// 'create',
];
}
protected function getGroupName() {
return 'changes';
}
}
/**
* Retain the old class name for backwards compatibility.
* @deprecated since 1.41
*/
class_alias( SpecialTags::class, 'SpecialTags' );