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