MediaWiki master
SpecialEditTags.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Specials;
22
26use LogPage;
39
50 protected $wasSaved = false;
51
53 private $submitClicked;
54
56 private $ids;
57
59 private $targetObj;
60
62 private $typeName;
63
65 private $revList;
66
68 private $reason;
69
70 private PermissionManager $permissionManager;
71 private ChangeTagsStore $changeTagsStore;
72
78 public function __construct( PermissionManager $permissionManager, ChangeTagsStore $changeTagsStore ) {
79 parent::__construct( 'EditTags', 'changetags' );
80
81 $this->permissionManager = $permissionManager;
82 $this->changeTagsStore = $changeTagsStore;
83 }
84
85 public function doesWrites() {
86 return true;
87 }
88
89 public function execute( $par ) {
90 $this->checkPermissions();
91 $this->checkReadOnly();
92
93 $output = $this->getOutput();
94 $user = $this->getUser();
95 $request = $this->getRequest();
96
97 $this->setHeaders();
98 $this->outputHeader();
99
100 $output->addModules( [ 'mediawiki.misc-authed-curate' ] );
101 $output->addModuleStyles( [
102 'mediawiki.interface.helpers.styles',
103 'mediawiki.special'
104 ] );
105
106 $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' );
107
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', [] ) );
116 }
117 $this->ids = array_unique( array_filter( $this->ids ) );
118
119 // No targets?
120 if ( count( $this->ids ) == 0 ) {
121 throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' );
122 }
123
124 $this->typeName = $request->getVal( 'type' );
125 $this->targetObj = Title::newFromText( $request->getText( 'target' ) );
126
127 switch ( $this->typeName ) {
128 case 'logentry':
129 case 'logging':
130 $this->typeName = 'logentry';
131 break;
132 default:
133 $this->typeName = 'revision';
134 break;
135 }
136
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
144 );
145
146 $this->reason = $request->getVal( 'wpReason', '' );
147 // We need a target page!
148 if ( $this->targetObj === null ) {
149 $output->addWikiMsg( 'undelete-header' );
150 return;
151 }
152
153 // Check blocks
154 $checkReplica = !$this->submitClicked;
155 if (
156 $this->permissionManager->isBlockedFrom(
157 $user,
158 $this->targetObj,
159 $checkReplica
160 )
161 ) {
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()
168 );
169 }
170
171 // Give a link to the logs/hist for this page
172 $this->showConvenienceLinks();
173
174 // Either submit or create our form
175 if ( $this->submitClicked ) {
176 $this->submit();
177 } else {
178 $this->showForm();
179 }
180
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 ]
190 );
191 }
192
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 );
201
202 $linkRenderer = $this->getLinkRenderer();
203 $links = [];
204 $links[] = $linkRenderer->makeKnownLink(
206 $this->msg( 'viewpagelogs' )->text(),
207 [],
208 [
209 'page' => $this->targetObj->getPrefixedText(),
210 'wpfilters' => [ 'tag' ],
211 ]
212 );
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(),
218 [],
219 [ 'action' => 'history' ]
220 );
221 }
222 // Link to Special:Tags
223 $links[] = $linkRenderer->makeKnownLink(
224 SpecialPage::getTitleFor( 'Tags' ),
225 $this->msg( 'tags-edit-manage-link' )->text()
226 );
227 // Logs themselves don't have histories or archived revisions
228 $this->getOutput()->addSubtitle( $this->getLanguage()->pipeList( $links ) );
229 }
230 }
231
236 protected function getList() {
237 if ( $this->revList === null ) {
238 $this->revList = ChangeTagsList::factory( $this->typeName, $this->getContext(),
239 $this->targetObj, $this->ids );
240 }
241
242 return $this->revList;
243 }
244
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 ] );
257
258 $this->addHelpLink( 'Help:Tags' );
259 $out->addHTML( "<ul>" );
260
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' );
268 }
269 $numRevisions++;
270 $out->addHTML( $item->getHTML() );
271 }
272
273 if ( !$numRevisions ) {
274 throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' );
275 }
276
277 $out->addHTML( "</ul>" );
278 // Explanation text
279 $out->wrapWikiMsg( '<p>$1</p>', "tags-edit-{$this->typeName}-explanation" );
280
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";
316
317 $out->addHTML( $form );
318 }
319
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 = [];
334 }
335
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();
343 }
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
350
351 for ( $list->reset(); $list->current(); $list->next() ) {
352 $currentTags = $list->current()->getTags();
353 if ( $currentTags ) {
354 $tags = array_merge( $tags, explode( ',', $currentTags ) );
355 }
356 }
357 $tags = array_unique( $tags );
358
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 );
375 }
376 }
377
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>';
382
383 return $html;
384 }
385
398 protected function getTagSelect( $selectedTags, $label ) {
399 $result = [];
400 $result[0] = Html::label( $label, 'mw-edittags-tag-list' );
401
402 $select = new XmlSelect( 'wpTagList[]', 'mw-edittags-tag-list', $selectedTags );
403 $select->setAttribute( 'multiple', 'multiple' );
404 $select->setAttribute( 'size', '8' );
405
406 $tags = $this->changeTagsStore->listExplicitlyDefinedTags();
407 $tags = array_unique( array_merge( $tags, $selectedTags ) );
408
409 // Values of $tags are also used as <option> labels
410 $select->addOptions( array_combine( $tags, $tags ) );
411
412 $result[1] = $select->getHTML();
413 return $result;
414 }
415
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;
427 }
428
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 );
436 }
437
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', [] );
445 }
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 );
452 }
453
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() );
459 }
460
461 if ( $status->isGood() ) {
462 $this->success();
463 return true;
464 } else {
465 $this->failure( $status );
466 return false;
467 }
468 }
469
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() )
478 );
479 $this->wasSaved = true;
480 $this->revList->reloadFromPrimary();
481 $this->reason = ''; // no need to spew the reason back at the user
482 $this->showForm();
483 }
484
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() )
496 )
497 )
498 );
499 $this->showForm();
500 }
501
502 public function getDescription() {
503 return $this->msg( 'tags-edit-title' );
504 }
505
506 protected function getGroupName() {
507 return 'pagetools';
508 }
509}
510
512class_alias( SpecialEditTags::class, 'SpecialEditTags' );
static factory( $typeName, IContextSource $context, PageIdentity $page, array $ids)
Creates a ChangeTags*List of the requested type.
An error page which can definitely be safely rendered using the OutputPage.
Class to simplify the use of log pages.
Definition LogPage.php:44
Gateway class for change_tags table.
Handle database storage of comments such as edit summaries and log reasons.
This class is a collection of static functions that serve two purposes:
Definition Html.php:56
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Parent class for all special pages.
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!
getSkin()
Shortcut to get the skin being used for this instance.
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,...
getUser()
Shortcut to get the User executing this instance.
getPageTitle( $subpage=false)
Get a self-referential title object.
checkPermissions()
Checks if userCanExecute, and if not throws a PermissionsError.
checkReadOnly()
If the wiki is currently in readonly mode, throws a ReadOnlyError.
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.
Shortcut to construct a special page which is unlisted by default.
Add or remove change tags to individual revisions.
success()
Report that the submit operation succeeded.
showConvenienceLinks()
Show some useful links in the subtitle.
failure( $status)
Report that the submit operation failed.
getTagSelect( $selectedTags, $label)
Returns a <select multiple> element with a list of change tags that can be applied by users.
showForm()
Show a list of items that we will operate on, and show a form which allows the user to modify the tag...
execute( $par)
Default execute method Checks user permissions.
doesWrites()
Indicates whether this special page may perform database writes.
bool $wasSaved
Was the DB modified in this request.
getList()
Get the list object for this request.
getGroupName()
Under which header this special page is listed in Special:SpecialPages See messages 'specialpages-gro...
getDescription()
Returns the name that goes in the <h1> in the special page itself, and also the name that will be l...
__construct(PermissionManager $permissionManager, ChangeTagsStore $changeTagsStore)
submit()
UI entry point for form submission.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
Represents a title within MediaWiki.
Definition Title.php:79
Class for generating HTML <select> or <datalist> elements.
Definition XmlSelect.php:30
Module of static functions for generating XML.
Definition Xml.php:37
General controller for RevDel, used by both SpecialRevisiondelete and ApiRevisionDelete.
Show an error when the user tries to do something whilst blocked.
element(SerializerNode $parent, SerializerNode $node, $contents)