Declare dynamic properties
[lhc/web/wiklou.git] / includes / specials / SpecialEditTags.php
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.
7 *
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.
12 *
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
17 *
18 * @file
19 * @ingroup SpecialPage
20 */
21
22 use MediaWiki\Permissions\PermissionManager;
23
24 /**
25 * Special page for adding and removing change tags to individual revisions.
26 * A lot of this is copied out of SpecialRevisiondelete.
27 *
28 * @ingroup SpecialPage
29 * @since 1.25
30 */
31 class SpecialEditTags extends UnlistedSpecialPage {
32 /** @var bool Was the DB modified in this request */
33 protected $wasSaved = false;
34
35 /** @var bool True if the submit button was clicked, and the form was posted */
36 private $submitClicked;
37
38 /** @var array Target ID list */
39 private $ids;
40
41 /** @var Title Title object for target parameter */
42 private $targetObj;
43
44 /** @var string Deletion type, may be revision or logentry */
45 private $typeName;
46
47 /** @var ChangeTagsList Storing the list of items to be tagged */
48 private $revList;
49
50 /** @var bool Whether user is allowed to perform the action */
51 private $isAllowed;
52
53 /** @var string */
54 private $reason;
55
56 /** @var PermissionManager */
57 private $permissionManager;
58
59 /**
60 * @inheritDoc
61 *
62 * @param PermissionManager $permissionManager
63 */
64 public function __construct( PermissionManager $permissionManager ) {
65 parent::__construct( 'EditTags', 'changetags' );
66
67 $this->permissionManager = $permissionManager;
68 }
69
70 public function doesWrites() {
71 return true;
72 }
73
74 public function execute( $par ) {
75 $this->checkPermissions();
76 $this->checkReadOnly();
77
78 $output = $this->getOutput();
79 $user = $this->getUser();
80 $request = $this->getRequest();
81
82 $this->setHeaders();
83 $this->outputHeader();
84
85 $output->addModules( [ 'mediawiki.special.edittags' ] );
86 $output->addModuleStyles( [
87 'mediawiki.interface.helpers.styles',
88 'mediawiki.special'
89 ] );
90
91 $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' );
92
93 // Handle our many different possible input types
94 $ids = $request->getVal( 'ids' );
95 if ( !is_null( $ids ) ) {
96 // Allow CSV from the form hidden field, or a single ID for show/hide links
97 $this->ids = explode( ',', $ids );
98 } else {
99 // Array input
100 $this->ids = array_keys( $request->getArray( 'ids', [] ) );
101 }
102 $this->ids = array_unique( array_filter( $this->ids ) );
103
104 // No targets?
105 if ( count( $this->ids ) == 0 ) {
106 throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' );
107 }
108
109 $this->typeName = $request->getVal( 'type' );
110 $this->targetObj = Title::newFromText( $request->getText( 'target' ) );
111
112 // sanity check of parameter
113 switch ( $this->typeName ) {
114 case 'logentry':
115 case 'logging':
116 $this->typeName = 'logentry';
117 break;
118 default:
119 $this->typeName = 'revision';
120 break;
121 }
122
123 // Allow the list type to adjust the passed target
124 // Yuck! Copied straight out of SpecialRevisiondelete, but it does exactly
125 // what we want
126 $this->targetObj = RevisionDeleter::suggestTarget(
127 $this->typeName === 'revision' ? 'revision' : 'logging',
128 $this->targetObj,
129 $this->ids
130 );
131
132 $this->isAllowed = $user->isAllowed( 'changetags' );
133
134 $this->reason = $request->getVal( 'wpReason' );
135 // We need a target page!
136 if ( is_null( $this->targetObj ) ) {
137 $output->addWikiMsg( 'undelete-header' );
138 return;
139 }
140
141 // Check blocks
142 if ( $this->permissionManager->isBlockedFrom( $user, $this->targetObj ) ) {
143 throw new UserBlockedError( $user->getBlock() );
144 }
145
146 // Give a link to the logs/hist for this page
147 $this->showConvenienceLinks();
148
149 // Either submit or create our form
150 if ( $this->isAllowed && $this->submitClicked ) {
151 $this->submit();
152 } else {
153 $this->showForm();
154 }
155
156 // Show relevant lines from the tag log
157 $tagLogPage = new LogPage( 'tag' );
158 $output->addHTML( "<h2>" . $tagLogPage->getName()->escaped() . "</h2>\n" );
159 LogEventsList::showLogExtract(
160 $output,
161 'tag',
162 $this->targetObj,
163 '', /* user */
164 [ 'lim' => 25, 'conds' => [], 'useMaster' => $this->wasSaved ]
165 );
166 }
167
168 /**
169 * Show some useful links in the subtitle
170 */
171 protected function showConvenienceLinks() {
172 // Give a link to the logs/hist for this page
173 if ( $this->targetObj ) {
174 // Also set header tabs to be for the target.
175 $this->getSkin()->setRelevantTitle( $this->targetObj );
176
177 $linkRenderer = $this->getLinkRenderer();
178 $links = [];
179 $links[] = $linkRenderer->makeKnownLink(
180 SpecialPage::getTitleFor( 'Log' ),
181 $this->msg( 'viewpagelogs' )->text(),
182 [],
183 [
184 'page' => $this->targetObj->getPrefixedText(),
185 'wpfilters' => [ 'tag' ],
186 ]
187 );
188 if ( !$this->targetObj->isSpecialPage() ) {
189 // Give a link to the page history
190 $links[] = $linkRenderer->makeKnownLink(
191 $this->targetObj,
192 $this->msg( 'pagehist' )->text(),
193 [],
194 [ 'action' => 'history' ]
195 );
196 }
197 // Link to Special:Tags
198 $links[] = $linkRenderer->makeKnownLink(
199 SpecialPage::getTitleFor( 'Tags' ),
200 $this->msg( 'tags-edit-manage-link' )->text()
201 );
202 // Logs themselves don't have histories or archived revisions
203 $this->getOutput()->addSubtitle( $this->getLanguage()->pipeList( $links ) );
204 }
205 }
206
207 /**
208 * Get the list object for this request
209 * @return ChangeTagsList
210 */
211 protected function getList() {
212 if ( is_null( $this->revList ) ) {
213 $this->revList = ChangeTagsList::factory( $this->typeName, $this->getContext(),
214 $this->targetObj, $this->ids );
215 }
216
217 return $this->revList;
218 }
219
220 /**
221 * Show a list of items that we will operate on, and show a form which allows
222 * the user to modify the tags applied to those items.
223 */
224 protected function showForm() {
225 $out = $this->getOutput();
226 // Messages: tags-edit-revision-selected, tags-edit-logentry-selected
227 $out->wrapWikiMsg( "<strong>$1</strong>", [
228 "tags-edit-{$this->typeName}-selected",
229 $this->getLanguage()->formatNum( count( $this->ids ) ),
230 $this->targetObj->getPrefixedText()
231 ] );
232
233 $this->addHelpLink( 'Help:Tags' );
234 $out->addHTML( "<ul>" );
235
236 $numRevisions = 0;
237 // Live revisions...
238 $list = $this->getList();
239 for ( $list->reset(); $list->current(); $list->next() ) {
240 $item = $list->current();
241 if ( !$item->canView() ) {
242 throw new ErrorPageError( 'permissionserrors', 'tags-update-no-permission' );
243 }
244 $numRevisions++;
245 $out->addHTML( $item->getHTML() );
246 }
247
248 if ( !$numRevisions ) {
249 throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' );
250 }
251
252 $out->addHTML( "</ul>" );
253 // Explanation text
254 $out->wrapWikiMsg( '<p>$1</p>', "tags-edit-{$this->typeName}-explanation" );
255
256 // Show form if the user can submit
257 if ( $this->isAllowed ) {
258 $form = Xml::openElement( 'form', [ 'method' => 'post',
259 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ),
260 'id' => 'mw-revdel-form-revisions' ] ) .
261 Xml::fieldset( $this->msg( "tags-edit-{$this->typeName}-legend",
262 count( $this->ids ) )->text() ) .
263 $this->buildCheckBoxes() .
264 Xml::openElement( 'table' ) .
265 "<tr>\n" .
266 '<td class="mw-label">' .
267 Xml::label( $this->msg( 'tags-edit-reason' )->text(), 'wpReason' ) .
268 '</td>' .
269 '<td class="mw-input">' .
270 Xml::input( 'wpReason', 60, $this->reason, [
271 'id' => 'wpReason',
272 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
273 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
274 // Unicode codepoints.
275 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
276 ] ) .
277 '</td>' .
278 "</tr><tr>\n" .
279 '<td></td>' .
280 '<td class="mw-submit">' .
281 Xml::submitButton( $this->msg( "tags-edit-{$this->typeName}-submit",
282 $numRevisions )->text(), [ 'name' => 'wpSubmit' ] ) .
283 '</td>' .
284 "</tr>\n" .
285 Xml::closeElement( 'table' ) .
286 Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) .
287 Html::hidden( 'target', $this->targetObj->getPrefixedText() ) .
288 Html::hidden( 'type', $this->typeName ) .
289 Html::hidden( 'ids', implode( ',', $this->ids ) ) .
290 Xml::closeElement( 'fieldset' ) . "\n" .
291 Xml::closeElement( 'form' ) . "\n";
292 } else {
293 $form = '';
294 }
295 $out->addHTML( $form );
296 }
297
298 /**
299 * @return string HTML
300 */
301 protected function buildCheckBoxes() {
302 // If there is just one item, provide the user with a multi-select field
303 $list = $this->getList();
304 $tags = [];
305 if ( $list->length() == 1 ) {
306 $list->reset();
307 $tags = $list->current()->getTags();
308 if ( $tags ) {
309 $tags = explode( ',', $tags );
310 } else {
311 $tags = [];
312 }
313
314 $html = '<table id="mw-edittags-tags-selector">';
315 $html .= '<tr><td>' . $this->msg( 'tags-edit-existing-tags' )->escaped() .
316 '</td><td>';
317 if ( $tags ) {
318 $html .= $this->getLanguage()->commaList( array_map( 'htmlspecialchars', $tags ) );
319 } else {
320 $html .= $this->msg( 'tags-edit-existing-tags-none' )->parse();
321 }
322 $html .= '</td></tr>';
323 $tagSelect = $this->getTagSelect( $tags, $this->msg( 'tags-edit-new-tags' )->plain() );
324 $html .= '<tr><td>' . $tagSelect[0] . '</td><td>' . $tagSelect[1];
325 } else {
326 // Otherwise, use a multi-select field for adding tags, and a list of
327 // checkboxes for removing them
328
329 for ( $list->reset(); $list->current(); $list->next() ) {
330 $currentTags = $list->current()->getTags();
331 if ( $currentTags ) {
332 $tags = array_merge( $tags, explode( ',', $currentTags ) );
333 }
334 }
335 $tags = array_unique( $tags );
336
337 $html = '<table id="mw-edittags-tags-selector-multi"><tr><td>';
338 $tagSelect = $this->getTagSelect( [], $this->msg( 'tags-edit-add' )->plain() );
339 $html .= '<p>' . $tagSelect[0] . '</p>' . $tagSelect[1] . '</td><td>';
340 $html .= Xml::element( 'p', null, $this->msg( 'tags-edit-remove' )->plain() );
341 $html .= Xml::checkLabel( $this->msg( 'tags-edit-remove-all-tags' )->plain(),
342 'wpRemoveAllTags', 'mw-edittags-remove-all' );
343 $i = 0; // used for generating checkbox IDs only
344 foreach ( $tags as $tag ) {
345 $html .= Xml::element( 'br' ) . "\n" . Xml::checkLabel( $tag,
346 'wpTagsToRemove[]', 'mw-edittags-remove-' . $i++, false, [
347 'value' => $tag,
348 'class' => 'mw-edittags-remove-checkbox',
349 ] );
350 }
351 }
352
353 // also output the tags currently applied as a hidden form field, so we
354 // know what to remove from the revision/log entry when the form is submitted
355 $html .= Html::hidden( 'wpExistingTags', implode( ',', $tags ) );
356 $html .= '</td></tr></table>';
357
358 return $html;
359 }
360
361 /**
362 * Returns a <select multiple> element with a list of change tags that can be
363 * applied by users.
364 *
365 * @param array $selectedTags The tags that should be preselected in the
366 * list. Any tags in this list, but not in the list returned by
367 * ChangeTags::listExplicitlyDefinedTags, will be appended to the <select>
368 * element.
369 * @param string $label The text of a <label> to precede the <select>
370 * @return array HTML <label> element at index 0, HTML <select> element at
371 * index 1
372 */
373 protected function getTagSelect( $selectedTags, $label ) {
374 $result = [];
375 $result[0] = Xml::label( $label, 'mw-edittags-tag-list' );
376
377 $select = new XmlSelect( 'wpTagList[]', 'mw-edittags-tag-list', $selectedTags );
378 $select->setAttribute( 'multiple', 'multiple' );
379 $select->setAttribute( 'size', '8' );
380
381 $tags = ChangeTags::listExplicitlyDefinedTags();
382 $tags = array_unique( array_merge( $tags, $selectedTags ) );
383
384 // Values of $tags are also used as <option> labels
385 $select->addOptions( array_combine( $tags, $tags ) );
386
387 $result[1] = $select->getHTML();
388 return $result;
389 }
390
391 /**
392 * UI entry point for form submission.
393 * @return bool
394 */
395 protected function submit() {
396 // Check edit token on submission
397 $request = $this->getRequest();
398 $token = $request->getVal( 'wpEditToken' );
399 if ( $this->submitClicked && !$this->getUser()->matchEditToken( $token ) ) {
400 $this->getOutput()->addWikiMsg( 'sessionfailure' );
401 return false;
402 }
403
404 // Evaluate incoming request data
405 $tagList = $request->getArray( 'wpTagList' );
406 if ( is_null( $tagList ) ) {
407 $tagList = [];
408 }
409 $existingTags = $request->getVal( 'wpExistingTags' );
410 if ( is_null( $existingTags ) || $existingTags === '' ) {
411 $existingTags = [];
412 } else {
413 $existingTags = explode( ',', $existingTags );
414 }
415
416 if ( count( $this->ids ) > 1 ) {
417 // multiple revisions selected
418 $tagsToAdd = $tagList;
419 if ( $request->getBool( 'wpRemoveAllTags' ) ) {
420 $tagsToRemove = $existingTags;
421 } else {
422 $tagsToRemove = $request->getArray( 'wpTagsToRemove' );
423 }
424 } else {
425 // single revision selected
426 // The user tells us which tags they want associated to the revision.
427 // We have to figure out which ones to add, and which to remove.
428 $tagsToAdd = array_diff( $tagList, $existingTags );
429 $tagsToRemove = array_diff( $existingTags, $tagList );
430 }
431
432 if ( !$tagsToAdd && !$tagsToRemove ) {
433 $status = Status::newFatal( 'tags-edit-none-selected' );
434 } else {
435 $status = $this->getList()->updateChangeTagsOnAll( $tagsToAdd,
436 $tagsToRemove, null, $this->reason, $this->getUser() );
437 }
438
439 if ( $status->isGood() ) {
440 $this->success();
441 return true;
442 } else {
443 $this->failure( $status );
444 return false;
445 }
446 }
447
448 /**
449 * Report that the submit operation succeeded
450 */
451 protected function success() {
452 $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' ) );
453 $this->getOutput()->wrapWikiMsg( "<div class=\"successbox\">\n$1\n</div>",
454 'tags-edit-success' );
455 $this->wasSaved = true;
456 $this->revList->reloadFromMaster();
457 $this->reason = ''; // no need to spew the reason back at the user
458 $this->showForm();
459 }
460
461 /**
462 * Report that the submit operation failed
463 * @param Status $status
464 */
465 protected function failure( $status ) {
466 $this->getOutput()->setPageTitle( $this->msg( 'actionfailed' ) );
467 $this->getOutput()->wrapWikiTextAsInterface(
468 'errorbox', $status->getWikiText( 'tags-edit-failure' )
469 );
470 $this->showForm();
471 }
472
473 public function getDescription() {
474 return $this->msg( 'tags-edit-title' )->text();
475 }
476
477 protected function getGroupName() {
478 return 'pagetools';
479 }
480 }