MediaWiki master
SpecialEditTags.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Specials;
22
38
49 protected $wasSaved = false;
50
52 private $submitClicked;
53
55 private $ids;
56
58 private $targetObj;
59
61 private $typeName;
62
64 private $revList;
65
67 private $reason;
68
69 private PermissionManager $permissionManager;
70 private ChangeTagsStore $changeTagsStore;
71
72 public function __construct( PermissionManager $permissionManager, ChangeTagsStore $changeTagsStore ) {
73 parent::__construct( 'EditTags', 'changetags' );
74
75 $this->permissionManager = $permissionManager;
76 $this->changeTagsStore = $changeTagsStore;
77 }
78
79 public function doesWrites() {
80 return true;
81 }
82
83 public function execute( $par ) {
84 $this->checkPermissions();
85 $this->checkReadOnly();
86
87 $output = $this->getOutput();
88 $user = $this->getUser();
89 $request = $this->getRequest();
90
91 $this->setHeaders();
92 $this->outputHeader();
93
94 $output->addModules( [ 'mediawiki.misc-authed-curate' ] );
95 $output->addModuleStyles( [
96 'mediawiki.interface.helpers.styles',
97 'mediawiki.special'
98 ] );
99
100 $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' );
101
102 // Handle our many different possible input types
103 $ids = $request->getVal( 'ids' );
104 if ( $ids !== null ) {
105 // Allow CSV from the form hidden field, or a single ID for show/hide links
106 $this->ids = explode( ',', $ids );
107 } else {
108 // Array input
109 $this->ids = array_keys( $request->getArray( 'ids', [] ) );
110 }
111 $this->ids = array_unique( array_filter( $this->ids ) );
112
113 // No targets?
114 if ( count( $this->ids ) == 0 ) {
115 throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' );
116 }
117
118 $this->typeName = $request->getVal( 'type' );
119 $this->targetObj = Title::newFromText( $request->getText( 'target' ) );
120
121 switch ( $this->typeName ) {
122 case 'logentry':
123 case 'logging':
124 $this->typeName = 'logentry';
125 break;
126 default:
127 $this->typeName = 'revision';
128 break;
129 }
130
131 // Allow the list type to adjust the passed target
132 // Yuck! Copied straight out of SpecialRevisiondelete, but it does exactly
133 // what we want
134 $this->targetObj = RevisionDeleter::suggestTarget(
135 $this->typeName === 'revision' ? 'revision' : 'logging',
136 $this->targetObj,
137 $this->ids
138 );
139
140 $this->reason = $request->getVal( 'wpReason', '' );
141 // We need a target page!
142 if ( $this->targetObj === null ) {
143 $output->addWikiMsg( 'undelete-header' );
144 return;
145 }
146
147 // Check blocks
148 $checkReplica = !$this->submitClicked;
149 if (
150 $this->permissionManager->isBlockedFrom(
151 $user,
152 $this->targetObj,
153 $checkReplica
154 )
155 ) {
156 throw new UserBlockedError(
157 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
158 $user->getBlock(),
159 $user,
160 $this->getLanguage(),
161 $request->getIP()
162 );
163 }
164
165 // Give a link to the logs/hist for this page
166 $this->showConvenienceLinks();
167
168 // Either submit or create our form
169 if ( $this->submitClicked ) {
170 $this->submit();
171 } else {
172 $this->showForm();
173 }
174
175 // Show relevant lines from the tag log
176 $tagLogPage = new LogPage( 'tag' );
177 $output->addHTML( "<h2>" . $tagLogPage->getName()->escaped() . "</h2>\n" );
178 LogEventsList::showLogExtract(
179 $output,
180 'tag',
181 $this->targetObj,
182 '', /* user */
183 [ 'lim' => 25, 'conds' => [], 'useMaster' => $this->wasSaved ]
184 );
185 }
186
190 protected function showConvenienceLinks() {
191 // Give a link to the logs/hist for this page
192 if ( $this->targetObj ) {
193 // Also set header tabs to be for the target.
194 $this->getSkin()->setRelevantTitle( $this->targetObj );
195
196 $linkRenderer = $this->getLinkRenderer();
197 $links = [];
198 $links[] = $linkRenderer->makeKnownLink(
200 $this->msg( 'viewpagelogs' )->text(),
201 [],
202 [
203 'page' => $this->targetObj->getPrefixedText(),
204 'wpfilters' => [ 'tag' ],
205 ]
206 );
207 if ( !$this->targetObj->isSpecialPage() ) {
208 // Give a link to the page history
209 $links[] = $linkRenderer->makeKnownLink(
210 $this->targetObj,
211 $this->msg( 'pagehist' )->text(),
212 [],
213 [ 'action' => 'history' ]
214 );
215 }
216 // Link to Special:Tags
217 $links[] = $linkRenderer->makeKnownLink(
218 SpecialPage::getTitleFor( 'Tags' ),
219 $this->msg( 'tags-edit-manage-link' )->text()
220 );
221 // Logs themselves don't have histories or archived revisions
222 $this->getOutput()->addSubtitle( $this->getLanguage()->pipeList( $links ) );
223 }
224 }
225
230 protected function getList() {
231 if ( $this->revList === null ) {
232 $this->revList = ChangeTagsList::factory( $this->typeName, $this->getContext(),
233 $this->targetObj, $this->ids );
234 }
235
236 return $this->revList;
237 }
238
243 protected function showForm() {
244 $out = $this->getOutput();
245 // Messages: tags-edit-revision-selected, tags-edit-logentry-selected
246 $out->wrapWikiMsg( "<strong>$1</strong>", [
247 "tags-edit-{$this->typeName}-selected",
248 $this->getLanguage()->formatNum( count( $this->ids ) ),
249 $this->targetObj->getPrefixedText()
250 ] );
251
252 $this->addHelpLink( 'Help:Tags' );
253 $out->addHTML( "<ul>" );
254
255 $numRevisions = 0;
256 // Live revisions...
257 $list = $this->getList();
258 for ( $list->reset(); $list->current(); $list->next() ) {
259 $item = $list->current();
260 if ( !$item->canView() ) {
261 throw new ErrorPageError( 'permissionserrors', 'tags-update-no-permission' );
262 }
263 $numRevisions++;
264 $out->addHTML( $item->getHTML() );
265 }
266
267 if ( !$numRevisions ) {
268 throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' );
269 }
270
271 $out->addHTML( "</ul>" );
272 // Explanation text
273 $out->wrapWikiMsg( '<p>$1</p>', "tags-edit-{$this->typeName}-explanation" );
274
275 // Show form
276 $form = Html::openElement( 'form', [ 'method' => 'post',
277 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ),
278 'id' => 'mw-revdel-form-revisions' ] ) .
279 Html::openElement( 'fieldset' ) .
281 'legend', [],
282 $this->msg( "tags-edit-{$this->typeName}-legend", count( $this->ids ) )->text()
283 ) .
284 $this->buildCheckBoxes() .
285 Html::openElement( 'table' ) .
286 "<tr>\n" .
287 '<td class="mw-label">' .
288 Html::label( $this->msg( 'tags-edit-reason' )->text(), 'wpReason' ) .
289 '</td>' .
290 '<td class="mw-input">' .
291 Html::element( 'input', [ 'name' => 'wpReason', 'size' => 60, 'value' => $this->reason,
292 'id' => 'wpReason',
293 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
294 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
295 // Unicode codepoints.
296 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
297 ] ) .
298 '</td>' .
299 "</tr><tr>\n" .
300 '<td></td>' .
301 '<td class="mw-submit">' .
302 Html::submitButton( $this->msg( "tags-edit-{$this->typeName}-submit",
303 $numRevisions )->text(), [ 'name' => 'wpSubmit' ] ) .
304 '</td>' .
305 "</tr>\n" .
306 Html::closeElement( 'table' ) .
307 Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) .
308 Html::hidden( 'target', $this->targetObj->getPrefixedText() ) .
309 Html::hidden( 'type', $this->typeName ) .
310 Html::hidden( 'ids', implode( ',', $this->ids ) ) .
311 Html::closeElement( 'fieldset' ) . "\n" .
312 Html::closeElement( 'form' ) . "\n";
313
314 $out->addHTML( $form );
315 }
316
320 protected function buildCheckBoxes() {
321 // If there is just one item, provide the user with a multi-select field
322 $list = $this->getList();
323 $tags = [];
324 if ( $list->length() == 1 ) {
325 $list->reset();
326 $tags = $list->current()->getTags();
327 if ( $tags ) {
328 $tags = explode( ',', $tags );
329 } else {
330 $tags = [];
331 }
332
333 $html = '<table id="mw-edittags-tags-selector">';
334 $html .= '<tr><td>' . $this->msg( 'tags-edit-existing-tags' )->escaped() .
335 '</td><td>';
336 if ( $tags ) {
337 $html .= $this->getLanguage()->commaList( array_map( 'htmlspecialchars', $tags ) );
338 } else {
339 $html .= $this->msg( 'tags-edit-existing-tags-none' )->parse();
340 }
341 $html .= '</td></tr>';
342 $tagSelect = $this->getTagSelect( $tags, $this->msg( 'tags-edit-new-tags' )->plain() );
343 $html .= '<tr><td>' . $tagSelect[0] . '</td><td>' . $tagSelect[1];
344 } else {
345 // Otherwise, use a multi-select field for adding tags, and a list of
346 // checkboxes for removing them
347
348 for ( $list->reset(); $list->current(); $list->next() ) {
349 $currentTags = $list->current()->getTags();
350 if ( $currentTags ) {
351 $tags = array_merge( $tags, explode( ',', $currentTags ) );
352 }
353 }
354 $tags = array_unique( $tags );
355
356 $html = '<table id="mw-edittags-tags-selector-multi"><tr><td>';
357 $tagSelect = $this->getTagSelect( [], $this->msg( 'tags-edit-add' )->plain() );
358 $html .= '<p>' . $tagSelect[0] . '</p>' . $tagSelect[1] . '</td><td>';
359 $html .= Html::element( 'p', [], $this->msg( 'tags-edit-remove' )->plain() );
360 $html .= Html::element( 'input', [
361 'type' => 'checkbox', 'name' => 'wpRemoveAllTags', 'value' => '1',
362 'id' => 'mw-edittags-remove-all'
363 ] ) . '&nbsp;'
364 . Html::label( $this->msg( 'tags-edit-remove-all-tags' )->plain(), 'mw-edittags-remove-all' );
365 $i = 0; // used for generating checkbox IDs only
366 foreach ( $tags as $tag ) {
367 $id = 'mw-edittags-remove-' . $i++;
368 $html .= Html::element( 'br' ) . "\n" . Html::element( 'input', [
369 'type' => 'checkbox', 'name' => 'wpTagsToRemove[]', 'value' => $tag,
370 'class' => 'mw-edittags-remove-checkbox', 'id' => $id,
371 ] ) . '&nbsp;' . Html::label( $tag, $id );
372 }
373 }
374
375 // also output the tags currently applied as a hidden form field, so we
376 // know what to remove from the revision/log entry when the form is submitted
377 $html .= Html::hidden( 'wpExistingTags', implode( ',', $tags ) );
378 $html .= '</td></tr></table>';
379
380 return $html;
381 }
382
395 protected function getTagSelect( $selectedTags, $label ) {
396 $result = [];
397 $result[0] = Html::label( $label, 'mw-edittags-tag-list' );
398
399 $select = new XmlSelect( 'wpTagList[]', 'mw-edittags-tag-list', $selectedTags );
400 $select->setAttribute( 'multiple', 'multiple' );
401 $select->setAttribute( 'size', '8' );
402
403 $tags = $this->changeTagsStore->listExplicitlyDefinedTags();
404 $tags = array_unique( array_merge( $tags, $selectedTags ) );
405
406 // Values of $tags are also used as <option> labels
407 $select->addOptions( array_combine( $tags, $tags ) );
408
409 $result[1] = $select->getHTML();
410 return $result;
411 }
412
417 protected function submit() {
418 // Check edit token on submission
419 $request = $this->getRequest();
420 $token = $request->getVal( 'wpEditToken' );
421 if ( $this->submitClicked && !$this->getUser()->matchEditToken( $token ) ) {
422 $this->getOutput()->addWikiMsg( 'sessionfailure' );
423 return false;
424 }
425
426 // Evaluate incoming request data
427 $tagList = $request->getArray( 'wpTagList' ) ?? [];
428 $existingTags = $request->getVal( 'wpExistingTags' );
429 if ( $existingTags === null || $existingTags === '' ) {
430 $existingTags = [];
431 } else {
432 $existingTags = explode( ',', $existingTags );
433 }
434
435 if ( count( $this->ids ) > 1 ) {
436 // multiple revisions selected
437 $tagsToAdd = $tagList;
438 if ( $request->getBool( 'wpRemoveAllTags' ) ) {
439 $tagsToRemove = $existingTags;
440 } else {
441 $tagsToRemove = $request->getArray( 'wpTagsToRemove', [] );
442 }
443 } else {
444 // single revision selected
445 // The user tells us which tags they want associated to the revision.
446 // We have to figure out which ones to add, and which to remove.
447 $tagsToAdd = array_diff( $tagList, $existingTags );
448 $tagsToRemove = array_diff( $existingTags, $tagList );
449 }
450
451 if ( !$tagsToAdd && !$tagsToRemove ) {
452 $status = Status::newFatal( 'tags-edit-none-selected' );
453 } else {
454 $status = $this->getList()->updateChangeTagsOnAll( $tagsToAdd,
455 $tagsToRemove, null, $this->reason, $this->getAuthority() );
456 }
457
458 if ( $status->isGood() ) {
459 $this->success();
460 return true;
461 } else {
462 $this->failure( $status );
463 return false;
464 }
465 }
466
470 protected function success() {
471 $out = $this->getOutput();
472 $out->setPageTitleMsg( $this->msg( 'actioncomplete' ) );
473 $out->addHTML(
474 Html::successBox( $out->msg( 'tags-edit-success' )->parse() )
475 );
476 $this->wasSaved = true;
477 $this->revList->reloadFromPrimary();
478 $this->reason = ''; // no need to spew the reason back at the user
479 $this->showForm();
480 }
481
486 protected function failure( $status ) {
487 $out = $this->getOutput();
488 $out->setPageTitleMsg( $this->msg( 'actionfailed' ) );
489 $out->addHTML(
490 Html::errorBox(
491 $out->parseAsContent(
492 $status->getWikiText( 'tags-edit-failure', false, $this->getLanguage() )
493 )
494 )
495 );
496 $this->showForm();
497 }
498
499 public function getDescription() {
500 return $this->msg( 'tags-edit-title' );
501 }
502
503 protected function getGroupName() {
504 return 'pagetools';
505 }
506}
507
509class_alias( SpecialEditTags::class, 'SpecialEditTags' );
static factory( $typeName, IContextSource $context, PageIdentity $page, array $ids)
Create a ChangeTagsList instance of the given type.
Read-write access to the change_tags table.
Handle database storage of comments such as edit summaries and log reasons.
An error page which can definitely be safely rendered using the OutputPage.
Show an error when the user tries to do something whilst blocked.
This class is a collection of static functions that serve two purposes:
Definition Html.php:57
Class to simplify the use of log pages.
Definition LogPage.php:50
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 POST requests to this special page require write access to the wiki.
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:78
Class for generating HTML <select> or <datalist> elements.
Definition XmlSelect.php:30
General controller for RevDel, used by both SpecialRevisiondelete and ApiRevisionDelete.
element(SerializerNode $parent, SerializerNode $node, $contents)