Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 250 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
SpecialEditTags | |
0.00% |
0 / 249 |
|
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 / 55 |
|
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 MediaWiki\ChangeTags\ChangeTagsList; |
24 | use MediaWiki\ChangeTags\ChangeTagsStore; |
25 | use MediaWiki\CommentStore\CommentStore; |
26 | use MediaWiki\Exception\ErrorPageError; |
27 | use MediaWiki\Exception\UserBlockedError; |
28 | use MediaWiki\Html\Html; |
29 | use MediaWiki\Logging\LogEventsList; |
30 | use MediaWiki\Logging\LogPage; |
31 | use MediaWiki\Permissions\PermissionManager; |
32 | use MediaWiki\SpecialPage\SpecialPage; |
33 | use MediaWiki\SpecialPage\UnlistedSpecialPage; |
34 | use MediaWiki\Status\Status; |
35 | use MediaWiki\Title\Title; |
36 | use MediaWiki\Xml\XmlSelect; |
37 | use RevisionDeleter; |
38 | |
39 | /** |
40 | * Add or remove change tags to individual revisions. |
41 | * |
42 | * A lot of this was copied out of SpecialRevisiondelete. |
43 | * |
44 | * @ingroup SpecialPage |
45 | * @since 1.25 |
46 | */ |
47 | class SpecialEditTags extends UnlistedSpecialPage { |
48 | /** @var bool Was the DB modified in this request */ |
49 | protected $wasSaved = false; |
50 | |
51 | /** @var bool True if the submit button was clicked, and the form was posted */ |
52 | private $submitClicked; |
53 | |
54 | /** @var array Target ID list */ |
55 | private $ids; |
56 | |
57 | /** @var Title Title object for target parameter */ |
58 | private $targetObj; |
59 | |
60 | /** @var string Deletion type, may be revision or logentry */ |
61 | private $typeName; |
62 | |
63 | /** @var ChangeTagsList Storing the list of items to be tagged */ |
64 | private $revList; |
65 | |
66 | /** @var string */ |
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 | |
187 | /** |
188 | * Show some useful links in the subtitle |
189 | */ |
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( |
199 | SpecialPage::getTitleFor( 'Log' ), |
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 | |
226 | /** |
227 | * Get the list object for this request |
228 | * @return ChangeTagsList |
229 | */ |
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 | |
239 | /** |
240 | * Show a list of items that we will operate on, and show a form which allows |
241 | * the user to modify the tags applied to those items. |
242 | */ |
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' ) . |
280 | Html::element( |
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 | |
317 | /** |
318 | * @return string HTML |
319 | */ |
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 | ] ) . ' ' |
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 | ] ) . ' ' . 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 | |
383 | /** |
384 | * Returns a <select multiple> element with a list of change tags that can be |
385 | * applied by users. |
386 | * |
387 | * @param array $selectedTags The tags that should be preselected in the |
388 | * list. Any tags in this list, but not in the list returned by |
389 | * ChangeTagsStore::listExplicitlyDefinedTags, will be appended to the <select> |
390 | * element. |
391 | * @param string $label The text of a <label> to precede the <select> |
392 | * @return array HTML <label> element at index 0, HTML <select> element at |
393 | * index 1 |
394 | */ |
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 | |
413 | /** |
414 | * UI entry point for form submission. |
415 | * @return bool |
416 | */ |
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 | |
467 | /** |
468 | * Report that the submit operation succeeded |
469 | */ |
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 | |
482 | /** |
483 | * Report that the submit operation failed |
484 | * @param Status $status |
485 | */ |
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 | |
508 | /** @deprecated class alias since 1.41 */ |
509 | class_alias( SpecialEditTags::class, 'SpecialEditTags' ); |