Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / specials / SpecialEditTags.php
blobc786d220fa2029835e0c98e57ed2c96dc61a00fa
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
18 * @file
21 namespace MediaWiki\Specials;
23 use ChangeTagsList;
24 use ErrorPageError;
25 use LogEventsList;
26 use LogPage;
27 use MediaWiki\ChangeTags\ChangeTagsStore;
28 use MediaWiki\CommentStore\CommentStore;
29 use MediaWiki\Html\Html;
30 use MediaWiki\Permissions\PermissionManager;
31 use MediaWiki\SpecialPage\SpecialPage;
32 use MediaWiki\SpecialPage\UnlistedSpecialPage;
33 use MediaWiki\Status\Status;
34 use MediaWiki\Title\Title;
35 use MediaWiki\Xml\Xml;
36 use MediaWiki\Xml\XmlSelect;
37 use RevisionDeleter;
38 use UserBlockedError;
40 /**
41 * Add or remove change tags to individual revisions.
43 * A lot of this was copied out of SpecialRevisiondelete.
45 * @ingroup SpecialPage
46 * @since 1.25
48 class SpecialEditTags extends UnlistedSpecialPage {
49 /** @var bool Was the DB modified in this request */
50 protected $wasSaved = false;
52 /** @var bool True if the submit button was clicked, and the form was posted */
53 private $submitClicked;
55 /** @var array Target ID list */
56 private $ids;
58 /** @var Title Title object for target parameter */
59 private $targetObj;
61 /** @var string Deletion type, may be revision or logentry */
62 private $typeName;
64 /** @var ChangeTagsList Storing the list of items to be tagged */
65 private $revList;
67 /** @var string */
68 private $reason;
70 private PermissionManager $permissionManager;
71 private ChangeTagsStore $changeTagsStore;
73 /**
74 * @inheritDoc
76 * @param PermissionManager $permissionManager
78 public function __construct( PermissionManager $permissionManager, ChangeTagsStore $changeTagsStore ) {
79 parent::__construct( 'EditTags', 'changetags' );
81 $this->permissionManager = $permissionManager;
82 $this->changeTagsStore = $changeTagsStore;
85 public function doesWrites() {
86 return true;
89 public function execute( $par ) {
90 $this->checkPermissions();
91 $this->checkReadOnly();
93 $output = $this->getOutput();
94 $user = $this->getUser();
95 $request = $this->getRequest();
97 $this->setHeaders();
98 $this->outputHeader();
100 $output->addModules( [ 'mediawiki.misc-authed-curate' ] );
101 $output->addModuleStyles( [
102 'mediawiki.interface.helpers.styles',
103 'mediawiki.special'
104 ] );
106 $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' );
108 // Handle our many different possible input types
109 $ids = $request->getVal( 'ids' );
110 if ( $ids !== null ) {
111 // Allow CSV from the form hidden field, or a single ID for show/hide links
112 $this->ids = explode( ',', $ids );
113 } else {
114 // Array input
115 $this->ids = array_keys( $request->getArray( 'ids', [] ) );
117 $this->ids = array_unique( array_filter( $this->ids ) );
119 // No targets?
120 if ( count( $this->ids ) == 0 ) {
121 throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' );
124 $this->typeName = $request->getVal( 'type' );
125 $this->targetObj = Title::newFromText( $request->getText( 'target' ) );
127 switch ( $this->typeName ) {
128 case 'logentry':
129 case 'logging':
130 $this->typeName = 'logentry';
131 break;
132 default:
133 $this->typeName = 'revision';
134 break;
137 // Allow the list type to adjust the passed target
138 // Yuck! Copied straight out of SpecialRevisiondelete, but it does exactly
139 // what we want
140 $this->targetObj = RevisionDeleter::suggestTarget(
141 $this->typeName === 'revision' ? 'revision' : 'logging',
142 $this->targetObj,
143 $this->ids
146 $this->reason = $request->getVal( 'wpReason', '' );
147 // We need a target page!
148 if ( $this->targetObj === null ) {
149 $output->addWikiMsg( 'undelete-header' );
150 return;
153 // Check blocks
154 $checkReplica = !$this->submitClicked;
155 if (
156 $this->permissionManager->isBlockedFrom(
157 $user,
158 $this->targetObj,
159 $checkReplica
162 throw new UserBlockedError(
163 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
164 $user->getBlock(),
165 $user,
166 $this->getLanguage(),
167 $request->getIP()
171 // Give a link to the logs/hist for this page
172 $this->showConvenienceLinks();
174 // Either submit or create our form
175 if ( $this->submitClicked ) {
176 $this->submit();
177 } else {
178 $this->showForm();
181 // Show relevant lines from the tag log
182 $tagLogPage = new LogPage( 'tag' );
183 $output->addHTML( "<h2>" . $tagLogPage->getName()->escaped() . "</h2>\n" );
184 LogEventsList::showLogExtract(
185 $output,
186 'tag',
187 $this->targetObj,
188 '', /* user */
189 [ 'lim' => 25, 'conds' => [], 'useMaster' => $this->wasSaved ]
194 * Show some useful links in the subtitle
196 protected function showConvenienceLinks() {
197 // Give a link to the logs/hist for this page
198 if ( $this->targetObj ) {
199 // Also set header tabs to be for the target.
200 $this->getSkin()->setRelevantTitle( $this->targetObj );
202 $linkRenderer = $this->getLinkRenderer();
203 $links = [];
204 $links[] = $linkRenderer->makeKnownLink(
205 SpecialPage::getTitleFor( 'Log' ),
206 $this->msg( 'viewpagelogs' )->text(),
209 'page' => $this->targetObj->getPrefixedText(),
210 'wpfilters' => [ 'tag' ],
213 if ( !$this->targetObj->isSpecialPage() ) {
214 // Give a link to the page history
215 $links[] = $linkRenderer->makeKnownLink(
216 $this->targetObj,
217 $this->msg( 'pagehist' )->text(),
219 [ 'action' => 'history' ]
222 // Link to Special:Tags
223 $links[] = $linkRenderer->makeKnownLink(
224 SpecialPage::getTitleFor( 'Tags' ),
225 $this->msg( 'tags-edit-manage-link' )->text()
227 // Logs themselves don't have histories or archived revisions
228 $this->getOutput()->addSubtitle( $this->getLanguage()->pipeList( $links ) );
233 * Get the list object for this request
234 * @return ChangeTagsList
236 protected function getList() {
237 if ( $this->revList === null ) {
238 $this->revList = ChangeTagsList::factory( $this->typeName, $this->getContext(),
239 $this->targetObj, $this->ids );
242 return $this->revList;
246 * Show a list of items that we will operate on, and show a form which allows
247 * the user to modify the tags applied to those items.
249 protected function showForm() {
250 $out = $this->getOutput();
251 // Messages: tags-edit-revision-selected, tags-edit-logentry-selected
252 $out->wrapWikiMsg( "<strong>$1</strong>", [
253 "tags-edit-{$this->typeName}-selected",
254 $this->getLanguage()->formatNum( count( $this->ids ) ),
255 $this->targetObj->getPrefixedText()
256 ] );
258 $this->addHelpLink( 'Help:Tags' );
259 $out->addHTML( "<ul>" );
261 $numRevisions = 0;
262 // Live revisions...
263 $list = $this->getList();
264 for ( $list->reset(); $list->current(); $list->next() ) {
265 $item = $list->current();
266 if ( !$item->canView() ) {
267 throw new ErrorPageError( 'permissionserrors', 'tags-update-no-permission' );
269 $numRevisions++;
270 $out->addHTML( $item->getHTML() );
273 if ( !$numRevisions ) {
274 throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' );
277 $out->addHTML( "</ul>" );
278 // Explanation text
279 $out->wrapWikiMsg( '<p>$1</p>', "tags-edit-{$this->typeName}-explanation" );
281 // Show form
282 $form = Html::openElement( 'form', [ 'method' => 'post',
283 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ),
284 'id' => 'mw-revdel-form-revisions' ] ) .
285 Xml::fieldset( $this->msg( "tags-edit-{$this->typeName}-legend",
286 count( $this->ids ) )->text() ) .
287 $this->buildCheckBoxes() .
288 Html::openElement( 'table' ) .
289 "<tr>\n" .
290 '<td class="mw-label">' .
291 Html::label( $this->msg( 'tags-edit-reason' )->text(), 'wpReason' ) .
292 '</td>' .
293 '<td class="mw-input">' .
294 Html::element( 'input', [ 'name' => 'wpReason', 'size' => 60, 'value' => $this->reason,
295 'id' => 'wpReason',
296 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
297 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
298 // Unicode codepoints.
299 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
300 ] ) .
301 '</td>' .
302 "</tr><tr>\n" .
303 '<td></td>' .
304 '<td class="mw-submit">' .
305 Html::submitButton( $this->msg( "tags-edit-{$this->typeName}-submit",
306 $numRevisions )->text(), [ 'name' => 'wpSubmit' ] ) .
307 '</td>' .
308 "</tr>\n" .
309 Html::closeElement( 'table' ) .
310 Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) .
311 Html::hidden( 'target', $this->targetObj->getPrefixedText() ) .
312 Html::hidden( 'type', $this->typeName ) .
313 Html::hidden( 'ids', implode( ',', $this->ids ) ) .
314 Html::closeElement( 'fieldset' ) . "\n" .
315 Html::closeElement( 'form' ) . "\n";
317 $out->addHTML( $form );
321 * @return string HTML
323 protected function buildCheckBoxes() {
324 // If there is just one item, provide the user with a multi-select field
325 $list = $this->getList();
326 $tags = [];
327 if ( $list->length() == 1 ) {
328 $list->reset();
329 $tags = $list->current()->getTags();
330 if ( $tags ) {
331 $tags = explode( ',', $tags );
332 } else {
333 $tags = [];
336 $html = '<table id="mw-edittags-tags-selector">';
337 $html .= '<tr><td>' . $this->msg( 'tags-edit-existing-tags' )->escaped() .
338 '</td><td>';
339 if ( $tags ) {
340 $html .= $this->getLanguage()->commaList( array_map( 'htmlspecialchars', $tags ) );
341 } else {
342 $html .= $this->msg( 'tags-edit-existing-tags-none' )->parse();
344 $html .= '</td></tr>';
345 $tagSelect = $this->getTagSelect( $tags, $this->msg( 'tags-edit-new-tags' )->plain() );
346 $html .= '<tr><td>' . $tagSelect[0] . '</td><td>' . $tagSelect[1];
347 } else {
348 // Otherwise, use a multi-select field for adding tags, and a list of
349 // checkboxes for removing them
351 for ( $list->reset(); $list->current(); $list->next() ) {
352 $currentTags = $list->current()->getTags();
353 if ( $currentTags ) {
354 $tags = array_merge( $tags, explode( ',', $currentTags ) );
357 $tags = array_unique( $tags );
359 $html = '<table id="mw-edittags-tags-selector-multi"><tr><td>';
360 $tagSelect = $this->getTagSelect( [], $this->msg( 'tags-edit-add' )->plain() );
361 $html .= '<p>' . $tagSelect[0] . '</p>' . $tagSelect[1] . '</td><td>';
362 $html .= Html::element( 'p', [], $this->msg( 'tags-edit-remove' )->plain() );
363 $html .= Html::element( 'input', [
364 'type' => 'checkbox', 'name' => 'wpRemoveAllTags', 'value' => '1',
365 'id' => 'mw-edittags-remove-all'
366 ] ) . '&nbsp;'
367 . Html::label( $this->msg( 'tags-edit-remove-all-tags' )->plain(), 'mw-edittags-remove-all' );
368 $i = 0; // used for generating checkbox IDs only
369 foreach ( $tags as $tag ) {
370 $id = 'mw-edittags-remove-' . $i++;
371 $html .= Html::element( 'br' ) . "\n" . Html::element( 'input', [
372 'type' => 'checkbox', 'name' => 'wpTagsToRemove[]', 'value' => $tag,
373 'class' => 'mw-edittags-remove-checkbox', 'id' => $id,
374 ] ) . '&nbsp;' . Html::label( $tag, $id );
378 // also output the tags currently applied as a hidden form field, so we
379 // know what to remove from the revision/log entry when the form is submitted
380 $html .= Html::hidden( 'wpExistingTags', implode( ',', $tags ) );
381 $html .= '</td></tr></table>';
383 return $html;
387 * Returns a <select multiple> element with a list of change tags that can be
388 * applied by users.
390 * @param array $selectedTags The tags that should be preselected in the
391 * list. Any tags in this list, but not in the list returned by
392 * ChangeTagsStore::listExplicitlyDefinedTags, will be appended to the <select>
393 * element.
394 * @param string $label The text of a <label> to precede the <select>
395 * @return array HTML <label> element at index 0, HTML <select> element at
396 * index 1
398 protected function getTagSelect( $selectedTags, $label ) {
399 $result = [];
400 $result[0] = Html::label( $label, 'mw-edittags-tag-list' );
402 $select = new XmlSelect( 'wpTagList[]', 'mw-edittags-tag-list', $selectedTags );
403 $select->setAttribute( 'multiple', 'multiple' );
404 $select->setAttribute( 'size', '8' );
406 $tags = $this->changeTagsStore->listExplicitlyDefinedTags();
407 $tags = array_unique( array_merge( $tags, $selectedTags ) );
409 // Values of $tags are also used as <option> labels
410 $select->addOptions( array_combine( $tags, $tags ) );
412 $result[1] = $select->getHTML();
413 return $result;
417 * UI entry point for form submission.
418 * @return bool
420 protected function submit() {
421 // Check edit token on submission
422 $request = $this->getRequest();
423 $token = $request->getVal( 'wpEditToken' );
424 if ( $this->submitClicked && !$this->getUser()->matchEditToken( $token ) ) {
425 $this->getOutput()->addWikiMsg( 'sessionfailure' );
426 return false;
429 // Evaluate incoming request data
430 $tagList = $request->getArray( 'wpTagList' ) ?? [];
431 $existingTags = $request->getVal( 'wpExistingTags' );
432 if ( $existingTags === null || $existingTags === '' ) {
433 $existingTags = [];
434 } else {
435 $existingTags = explode( ',', $existingTags );
438 if ( count( $this->ids ) > 1 ) {
439 // multiple revisions selected
440 $tagsToAdd = $tagList;
441 if ( $request->getBool( 'wpRemoveAllTags' ) ) {
442 $tagsToRemove = $existingTags;
443 } else {
444 $tagsToRemove = $request->getArray( 'wpTagsToRemove', [] );
446 } else {
447 // single revision selected
448 // The user tells us which tags they want associated to the revision.
449 // We have to figure out which ones to add, and which to remove.
450 $tagsToAdd = array_diff( $tagList, $existingTags );
451 $tagsToRemove = array_diff( $existingTags, $tagList );
454 if ( !$tagsToAdd && !$tagsToRemove ) {
455 $status = Status::newFatal( 'tags-edit-none-selected' );
456 } else {
457 $status = $this->getList()->updateChangeTagsOnAll( $tagsToAdd,
458 $tagsToRemove, null, $this->reason, $this->getAuthority() );
461 if ( $status->isGood() ) {
462 $this->success();
463 return true;
464 } else {
465 $this->failure( $status );
466 return false;
471 * Report that the submit operation succeeded
473 protected function success() {
474 $out = $this->getOutput();
475 $out->setPageTitleMsg( $this->msg( 'actioncomplete' ) );
476 $out->addHTML(
477 Html::successBox( $out->msg( 'tags-edit-success' )->parse() )
479 $this->wasSaved = true;
480 $this->revList->reloadFromPrimary();
481 $this->reason = ''; // no need to spew the reason back at the user
482 $this->showForm();
486 * Report that the submit operation failed
487 * @param Status $status
489 protected function failure( $status ) {
490 $out = $this->getOutput();
491 $out->setPageTitleMsg( $this->msg( 'actionfailed' ) );
492 $out->addHTML(
493 Html::errorBox(
494 $out->parseAsContent(
495 $status->getWikiText( 'tags-edit-failure', false, $this->getLanguage() )
499 $this->showForm();
502 public function getDescription() {
503 return $this->msg( 'tags-edit-title' );
506 protected function getGroupName() {
507 return 'pagetools';
511 /** @deprecated class alias since 1.41 */
512 class_alias( SpecialEditTags::class, 'SpecialEditTags' );