Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 247
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 / 246
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 / 52
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 * @ingroup SpecialPage
20 */
21
22namespace MediaWiki\Specials;
23
24use ChangeTagsList;
25use ErrorPageError;
26use LogEventsList;
27use LogPage;
28use MediaWiki\ChangeTags\ChangeTagsStore;
29use MediaWiki\CommentStore\CommentStore;
30use MediaWiki\Html\Html;
31use MediaWiki\Permissions\PermissionManager;
32use MediaWiki\SpecialPage\SpecialPage;
33use MediaWiki\SpecialPage\UnlistedSpecialPage;
34use MediaWiki\Status\Status;
35use MediaWiki\Title\Title;
36use RevisionDeleter;
37use UserBlockedError;
38use Xml;
39use XmlSelect;
40
41/**
42 * Special page for adding and removing change tags to individual revisions.
43 * A lot of this is copied out of SpecialRevisiondelete.
44 *
45 * @ingroup SpecialPage
46 * @since 1.25
47 */
48class 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            ] ) . '&nbsp;'
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                ] ) . '&nbsp;' . 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 */
512class_alias( SpecialEditTags::class, 'SpecialEditTags' );