Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 250
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialEditTags
0.00% covered (danger)
0.00%
0 / 249
0.00% covered (danger)
0.00%
0 / 13
1980
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
132
 showConvenienceLinks
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 getList
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 showForm
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
20
 buildCheckBoxes
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
56
 getTagSelect
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 submit
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
110
 success
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 failure
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
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
21namespace MediaWiki\Specials;
22
23use MediaWiki\ChangeTags\ChangeTagsList;
24use MediaWiki\ChangeTags\ChangeTagsStore;
25use MediaWiki\CommentStore\CommentStore;
26use MediaWiki\Exception\ErrorPageError;
27use MediaWiki\Exception\UserBlockedError;
28use MediaWiki\Html\Html;
29use MediaWiki\Logging\LogEventsList;
30use MediaWiki\Logging\LogPage;
31use MediaWiki\Permissions\PermissionManager;
32use MediaWiki\SpecialPage\SpecialPage;
33use MediaWiki\SpecialPage\UnlistedSpecialPage;
34use MediaWiki\Status\Status;
35use MediaWiki\Title\Title;
36use MediaWiki\Xml\XmlSelect;
37use 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 */
47class 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            ] ) . '&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
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 */
509class_alias( SpecialEditTags::class, 'SpecialEditTags' );