Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 247 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
SpecialEditTags | |
0.00% |
0 / 246 |
|
0.00% |
0 / 13 |
1980 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 63 |
|
0.00% |
0 / 1 |
132 | |||
showConvenienceLinks | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
12 | |||
getList | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
showForm | |
0.00% |
0 / 52 |
|
0.00% |
0 / 1 |
20 | |||
buildCheckBoxes | |
0.00% |
0 / 41 |
|
0.00% |
0 / 1 |
56 | |||
getTagSelect | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
submit | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
110 | |||
success | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
failure | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
getDescription | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
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 | */ |
20 | |
21 | namespace MediaWiki\Specials; |
22 | |
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; |
39 | |
40 | /** |
41 | * Add or remove change tags to individual revisions. |
42 | * |
43 | * A lot of this was copied out of SpecialRevisiondelete. |
44 | * |
45 | * @ingroup SpecialPage |
46 | * @since 1.25 |
47 | */ |
48 | class SpecialEditTags extends UnlistedSpecialPage { |
49 | /** @var bool Was the DB modified in this request */ |
50 | protected $wasSaved = false; |
51 | |
52 | /** @var bool True if the submit button was clicked, and the form was posted */ |
53 | private $submitClicked; |
54 | |
55 | /** @var array Target ID list */ |
56 | private $ids; |
57 | |
58 | /** @var Title Title object for target parameter */ |
59 | private $targetObj; |
60 | |
61 | /** @var string Deletion type, may be revision or logentry */ |
62 | private $typeName; |
63 | |
64 | /** @var ChangeTagsList Storing the list of items to be tagged */ |
65 | private $revList; |
66 | |
67 | /** @var string */ |
68 | private $reason; |
69 | |
70 | private PermissionManager $permissionManager; |
71 | private ChangeTagsStore $changeTagsStore; |
72 | |
73 | /** |
74 | * @inheritDoc |
75 | * |
76 | * @param PermissionManager $permissionManager |
77 | */ |
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 | |
193 | /** |
194 | * Show some useful links in the subtitle |
195 | */ |
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( |
205 | SpecialPage::getTitleFor( 'Log' ), |
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 | |
232 | /** |
233 | * Get the list object for this request |
234 | * @return ChangeTagsList |
235 | */ |
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 | |
245 | /** |
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. |
248 | */ |
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 | |
320 | /** |
321 | * @return string HTML |
322 | */ |
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 | ] ) . ' ' |
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 | ] ) . ' ' . 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 | |
386 | /** |
387 | * Returns a <select multiple> element with a list of change tags that can be |
388 | * applied by users. |
389 | * |
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 |
397 | */ |
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 | |
416 | /** |
417 | * UI entry point for form submission. |
418 | * @return bool |
419 | */ |
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 | |
470 | /** |
471 | * Report that the submit operation succeeded |
472 | */ |
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 | |
485 | /** |
486 | * Report that the submit operation failed |
487 | * @param Status $status |
488 | */ |
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 | |
511 | /** @deprecated class alias since 1.41 */ |
512 | class_alias( SpecialEditTags::class, 'SpecialEditTags' ); |