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