Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 292
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialTags
0.00% covered (danger)
0.00%
0 / 291
0.00% covered (danger)
0.00%
0 / 10
3422
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 showTagList
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 1
110
 doTagRow
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
342
 processCreateTagForm
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
 showDeleteTagForm
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
56
 showActivateDeactivateForm
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
42
 processTagForm
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 getSubpagesForPrefixSearch
0.00% covered (danger)
0.00%
0 / 6
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 * Implements Special:Tags
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup SpecialPage
22 */
23
24namespace MediaWiki\Specials;
25
26use ChangeTags;
27use MediaWiki\ChangeTags\ChangeTagsStore;
28use MediaWiki\CommentStore\CommentStore;
29use MediaWiki\HTMLForm\HTMLForm;
30use MediaWiki\MainConfigNames;
31use MediaWiki\SpecialPage\SpecialPage;
32use PermissionsError;
33use Xml;
34
35/**
36 * A special page that lists tags for edits
37 *
38 * @ingroup SpecialPage
39 */
40class SpecialTags extends SpecialPage {
41
42    /**
43     * @var array List of explicitly defined tags
44     */
45    protected $explicitlyDefinedTags;
46
47    /**
48     * @var array List of software defined tags
49     */
50    protected $softwareDefinedTags;
51
52    /**
53     * @var array List of software activated tags
54     */
55    protected $softwareActivatedTags;
56    private ChangeTagsStore $changeTagsStore;
57
58    public function __construct( ChangeTagsStore $changeTagsStore ) {
59        parent::__construct( 'Tags' );
60        $this->changeTagsStore = $changeTagsStore;
61    }
62
63    public function execute( $par ) {
64        $this->setHeaders();
65        $this->outputHeader();
66        $this->addHelpLink( 'Manual:Tags' );
67
68        $request = $this->getRequest();
69        switch ( $par ) {
70            case 'delete':
71                $this->showDeleteTagForm( $request->getVal( 'tag' ) );
72                break;
73            case 'activate':
74                $this->showActivateDeactivateForm( $request->getVal( 'tag' ), true );
75                break;
76            case 'deactivate':
77                $this->showActivateDeactivateForm( $request->getVal( 'tag' ), false );
78                break;
79            case 'create':
80                // fall through, thanks to HTMLForm's logic
81            default:
82                $this->showTagList();
83                break;
84        }
85    }
86
87    private function showTagList() {
88        $out = $this->getOutput();
89        $out->setPageTitleMsg( $this->msg( 'tags-title' ) );
90        $out->wrapWikiMsg( "<div class='mw-tags-intro'>\n$1\n</div>", 'tags-intro' );
91
92        $authority = $this->getAuthority();
93        $userCanManage = $authority->isAllowed( 'managechangetags' );
94        $userCanDelete = $authority->isAllowed( 'deletechangetags' );
95        $userCanEditInterface = $authority->isAllowed( 'editinterface' );
96
97        // Show form to create a tag
98        if ( $userCanManage ) {
99            $fields = [
100                'Tag' => [
101                    'type' => 'text',
102                    'label' => $this->msg( 'tags-create-tag-name' )->plain(),
103                    'required' => true,
104                ],
105                'Reason' => [
106                    'type' => 'text',
107                    'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
108                    'label' => $this->msg( 'tags-create-reason' )->plain(),
109                    'size' => 50,
110                ],
111                'IgnoreWarnings' => [
112                    'type' => 'hidden',
113                ],
114            ];
115
116            HTMLForm::factory( 'ooui', $fields, $this->getContext() )
117                ->setAction( $this->getPageTitle( 'create' )->getLocalURL() )
118                ->setWrapperLegendMsg( 'tags-create-heading' )
119                ->setHeaderHtml( $this->msg( 'tags-create-explanation' )->parseAsBlock() )
120                ->setSubmitCallback( [ $this, 'processCreateTagForm' ] )
121                ->setSubmitTextMsg( 'tags-create-submit' )
122                ->show();
123
124            // If processCreateTagForm generated a redirect, there's no point
125            // continuing with this, as the user is just going to end up getting sent
126            // somewhere else. Additionally, if we keep going here, we end up
127            // populating the memcache of tag data (see ChangeTags::listDefinedTags)
128            // with out-of-date data from the replica DB, because the replica DB hasn't caught
129            // up to the fact that a new tag has been created as part of an implicit,
130            // as yet uncommitted transaction on primary DB.
131            if ( $out->getRedirect() !== '' ) {
132                return;
133            }
134        }
135
136        // Used to get hitcounts for #doTagRow()
137        $tagStats = $this->changeTagsStore->tagUsageStatistics();
138
139        // Used in #doTagRow()
140        $this->explicitlyDefinedTags = array_fill_keys(
141            $this->changeTagsStore->listExplicitlyDefinedTags(), true );
142        $this->softwareDefinedTags = array_fill_keys(
143            $this->changeTagsStore->listSoftwareDefinedTags(), true );
144
145        // List all defined tags, even if they were never applied
146        $definedTags = array_keys( $this->explicitlyDefinedTags + $this->softwareDefinedTags );
147
148        // Show header only if there exists at least one tag
149        if ( !$tagStats && !$definedTags ) {
150            return;
151        }
152
153        // Write the headers
154        $thead = Xml::tags( 'tr', null, Xml::tags( 'th', null, $this->msg( 'tags-tag' )->parse() ) .
155            Xml::tags( 'th', null, $this->msg( 'tags-display-header' )->parse() ) .
156            Xml::tags( 'th', null, $this->msg( 'tags-description-header' )->parse() ) .
157            Xml::tags( 'th', null, $this->msg( 'tags-source-header' )->parse() ) .
158            Xml::tags( 'th', null, $this->msg( 'tags-active-header' )->parse() ) .
159            Xml::tags( 'th', null, $this->msg( 'tags-hitcount-header' )->parse() ) .
160            ( ( $userCanManage || $userCanDelete ) ?
161                Xml::tags( 'th', [ 'class' => 'unsortable' ],
162                    $this->msg( 'tags-actions-header' )->parse() ) :
163                '' )
164        );
165
166        $tbody = '';
167        // Used in #doTagRow()
168        $this->softwareActivatedTags = array_fill_keys(
169            $this->changeTagsStore->listSoftwareActivatedTags(), true );
170
171        // Insert tags that have been applied at least once
172        foreach ( $tagStats as $tag => $hitcount ) {
173            $tbody .= $this->doTagRow( $tag, $hitcount, $userCanManage,
174                $userCanDelete, $userCanEditInterface );
175        }
176        // Insert tags defined somewhere but never applied
177        foreach ( $definedTags as $tag ) {
178            if ( !isset( $tagStats[$tag] ) ) {
179                $tbody .= $this->doTagRow( $tag, 0, $userCanManage, $userCanDelete, $userCanEditInterface );
180            }
181        }
182
183        $out->addModuleStyles( [
184            'jquery.tablesorter.styles',
185            'mediawiki.pager.styles'
186        ] );
187        $out->addModules( 'jquery.tablesorter' );
188        $out->addHTML( Xml::tags(
189            'table',
190            [ 'class' => 'mw-datatable sortable mw-tags-table' ],
191            Xml::tags( 'thead', null, $thead ) .
192            Xml::tags( 'tbody', null, $tbody )
193        ) );
194    }
195
196    private function doTagRow(
197        $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks
198    ) {
199        $newRow = '';
200        $newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, $tag ) );
201
202        $linkRenderer = $this->getLinkRenderer();
203        $disp = ChangeTags::tagDescription( $tag, $this->getContext() );
204        if ( $disp === false ) {
205            $disp = Xml::element( 'em', null, $this->msg( 'tags-hidden' )->text() );
206        }
207        if ( $showEditLinks ) {
208            $disp .= ' ';
209            $editLink = $linkRenderer->makeLink(
210                $this->msg( "tag-$tag" )->inContentLanguage()->getTitle(),
211                $this->msg( 'tags-edit' )->text(),
212                [],
213                [ 'action' => 'edit' ]
214            );
215            $disp .= $this->msg( 'parentheses' )->rawParams( $editLink )->escaped();
216        }
217        $newRow .= Xml::tags( 'td', null, $disp );
218
219        $msg = $this->msg( "tag-$tag-description" );
220        $desc = !$msg->exists() ? '' : $msg->parse();
221        if ( $showEditLinks ) {
222            $desc .= ' ';
223            $editDescLink = $linkRenderer->makeLink(
224                $this->msg( "tag-$tag-description" )->inContentLanguage()->getTitle(),
225                $this->msg( 'tags-edit' )->text(),
226                [],
227                [ 'action' => 'edit' ]
228            );
229            $desc .= $this->msg( 'parentheses' )->rawParams( $editDescLink )->escaped();
230        }
231        $newRow .= Xml::tags( 'td', null, $desc );
232
233        $sourceMsgs = [];
234        $isSoftware = isset( $this->softwareDefinedTags[$tag] );
235        $isExplicit = isset( $this->explicitlyDefinedTags[$tag] );
236        if ( $isSoftware ) {
237            // TODO: Rename this message
238            $sourceMsgs[] = $this->msg( 'tags-source-extension' )->escaped();
239        }
240        if ( $isExplicit ) {
241            $sourceMsgs[] = $this->msg( 'tags-source-manual' )->escaped();
242        }
243        if ( !$sourceMsgs ) {
244            $sourceMsgs[] = $this->msg( 'tags-source-none' )->escaped();
245        }
246        $newRow .= Xml::tags( 'td', null, implode( Xml::element( 'br' ), $sourceMsgs ) );
247
248        $isActive = $isExplicit || isset( $this->softwareActivatedTags[$tag] );
249        $activeMsg = ( $isActive ? 'tags-active-yes' : 'tags-active-no' );
250        $newRow .= Xml::tags( 'td', null, $this->msg( $activeMsg )->escaped() );
251
252        $hitcountLabelMsg = $this->msg( 'tags-hitcount' )->numParams( $hitcount );
253        if ( $this->getConfig()->get( MainConfigNames::UseTagFilter ) ) {
254            $hitcountLabel = $linkRenderer->makeLink(
255                SpecialPage::getTitleFor( 'Recentchanges' ),
256                $hitcountLabelMsg->text(),
257                [],
258                [ 'tagfilter' => $tag ]
259            );
260        } else {
261            $hitcountLabel = $hitcountLabelMsg->escaped();
262        }
263
264        // add raw $hitcount for sorting, because tags-hitcount contains numbers and letters
265        $newRow .= Xml::tags( 'td', [ 'data-sort-value' => $hitcount ], $hitcountLabel );
266
267        $actionLinks = [];
268
269        if ( $showDeleteActions && ChangeTags::canDeleteTag( $tag )->isOK() ) {
270            $actionLinks[] = $linkRenderer->makeKnownLink(
271                $this->getPageTitle( 'delete' ),
272                $this->msg( 'tags-delete' )->text(),
273                [],
274                [ 'tag' => $tag ] );
275        }
276
277        if ( $showManageActions ) { // we've already checked that the user had the requisite userright
278            if ( ChangeTags::canActivateTag( $tag )->isOK() ) {
279                $actionLinks[] = $linkRenderer->makeKnownLink(
280                    $this->getPageTitle( 'activate' ),
281                    $this->msg( 'tags-activate' )->text(),
282                    [],
283                    [ 'tag' => $tag ] );
284            }
285
286            if ( ChangeTags::canDeactivateTag( $tag )->isOK() ) {
287                $actionLinks[] = $linkRenderer->makeKnownLink(
288                    $this->getPageTitle( 'deactivate' ),
289                    $this->msg( 'tags-deactivate' )->text(),
290                    [],
291                    [ 'tag' => $tag ] );
292            }
293        }
294
295        if ( $showDeleteActions || $showManageActions ) {
296            $newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) );
297        }
298
299        return Xml::tags( 'tr', null, $newRow ) . "\n";
300    }
301
302    public function processCreateTagForm( array $data, HTMLForm $form ) {
303        $context = $form->getContext();
304        $out = $context->getOutput();
305
306        $tag = trim( strval( $data['Tag'] ) );
307        $ignoreWarnings = isset( $data['IgnoreWarnings'] ) && $data['IgnoreWarnings'] === '1';
308        $status = ChangeTags::createTagWithChecks( $tag, $data['Reason'],
309            $context->getAuthority(), $ignoreWarnings );
310
311        if ( $status->isGood() ) {
312            $out->redirect( $this->getPageTitle()->getLocalURL() );
313            return true;
314        } elseif ( $status->isOK() ) {
315            // We have some warnings, so we adjust the form for confirmation.
316            // This would override the existing field and its default value.
317            $form->addFields( [
318                'IgnoreWarnings' => [
319                    'type' => 'hidden',
320                    'default' => '1',
321                ],
322            ] );
323
324            $headerText = $this->msg( 'tags-create-warnings-above', $tag,
325                count( $status->getWarningsArray() ) )->parseAsBlock() .
326                $out->parseAsInterface( $status->getWikiText() ) .
327                $this->msg( 'tags-create-warnings-below' )->parseAsBlock();
328
329            $form->setHeaderHtml( $headerText )
330                ->setSubmitTextMsg( 'htmlform-yes' );
331
332            $out->addBacklinkSubtitle( $this->getPageTitle() );
333            return false;
334        } else {
335            $out->wrapWikiTextAsInterface( 'error', $status->getWikiText() );
336            return false;
337        }
338    }
339
340    protected function showDeleteTagForm( $tag ) {
341        $authority = $this->getAuthority();
342        if ( !$authority->isAllowed( 'deletechangetags' ) ) {
343            throw new PermissionsError( 'deletechangetags' );
344        }
345
346        $out = $this->getOutput();
347        $out->setPageTitleMsg( $this->msg( 'tags-delete-title' ) );
348        $out->addBacklinkSubtitle( $this->getPageTitle() );
349
350        // is the tag actually able to be deleted?
351        $canDeleteResult = ChangeTags::canDeleteTag( $tag, $authority );
352        if ( !$canDeleteResult->isGood() ) {
353            $out->wrapWikiTextAsInterface( 'error', $canDeleteResult->getWikiText() );
354            if ( !$canDeleteResult->isOK() ) {
355                return;
356            }
357        }
358
359        $preText = $this->msg( 'tags-delete-explanation-initial', $tag )->parseAsBlock();
360        $tagUsage = $this->changeTagsStore->tagUsageStatistics();
361        if ( isset( $tagUsage[$tag] ) && $tagUsage[$tag] > 0 ) {
362            $preText .= $this->msg( 'tags-delete-explanation-in-use', $tag,
363                $tagUsage[$tag] )->parseAsBlock();
364        }
365        $preText .= $this->msg( 'tags-delete-explanation-warning', $tag )->parseAsBlock();
366
367        // see if the tag is in use
368        $this->softwareActivatedTags = array_fill_keys(
369            $this->changeTagsStore->listSoftwareActivatedTags(), true );
370        if ( isset( $this->softwareActivatedTags[$tag] ) ) {
371            $preText .= $this->msg( 'tags-delete-explanation-active', $tag )->parseAsBlock();
372        }
373
374        $fields = [];
375        $fields['Reason'] = [
376            'type' => 'text',
377            'label' => $this->msg( 'tags-delete-reason' )->plain(),
378            'size' => 50,
379        ];
380        $fields['HiddenTag'] = [
381            'type' => 'hidden',
382            'name' => 'tag',
383            'default' => $tag,
384            'required' => true,
385        ];
386
387        HTMLForm::factory( 'ooui', $fields, $this->getContext() )
388            ->setAction( $this->getPageTitle( 'delete' )->getLocalURL() )
389            ->setSubmitCallback( function ( $data, $form ) {
390                return $this->processTagForm( $data, $form, 'delete' );
391            } )
392            ->setSubmitTextMsg( 'tags-delete-submit' )
393            ->setSubmitDestructive()
394            ->addPreHtml( $preText )
395            ->show();
396    }
397
398    protected function showActivateDeactivateForm( $tag, $activate ) {
399        $actionStr = $activate ? 'activate' : 'deactivate';
400
401        $authority = $this->getAuthority();
402        if ( !$authority->isAllowed( 'managechangetags' ) ) {
403            throw new PermissionsError( 'managechangetags' );
404        }
405
406        $out = $this->getOutput();
407        // tags-activate-title, tags-deactivate-title
408        $out->setPageTitleMsg( $this->msg( "tags-$actionStr-title" ) );
409        $out->addBacklinkSubtitle( $this->getPageTitle() );
410
411        // is it possible to do this?
412        if ( $activate ) {
413            $result = ChangeTags::canActivateTag( $tag, $authority );
414        } else {
415            $result = ChangeTags::canDeactivateTag( $tag, $authority );
416        }
417        if ( !$result->isGood() ) {
418            $out->wrapWikiTextAsInterface( 'error', $result->getWikiText() );
419            if ( !$result->isOK() ) {
420                return;
421            }
422        }
423
424        // tags-activate-question, tags-deactivate-question
425        $preText = $this->msg( "tags-$actionStr-question", $tag )->parseAsBlock();
426
427        $fields = [];
428        // tags-activate-reason, tags-deactivate-reason
429        $fields['Reason'] = [
430            'type' => 'text',
431            'label' => $this->msg( "tags-$actionStr-reason" )->plain(),
432            'size' => 50,
433        ];
434        $fields['HiddenTag'] = [
435            'type' => 'hidden',
436            'name' => 'tag',
437            'default' => $tag,
438            'required' => true,
439        ];
440
441        HTMLForm::factory( 'ooui', $fields, $this->getContext() )
442            ->setAction( $this->getPageTitle( $actionStr )->getLocalURL() )
443            ->setSubmitCallback( function ( $data, $form ) use ( $actionStr ) {
444                return $this->processTagForm( $data, $form, $actionStr );
445            } )
446            // tags-activate-submit, tags-deactivate-submit
447            ->setSubmitTextMsg( "tags-$actionStr-submit" )
448            ->addPreHtml( $preText )
449            ->show();
450    }
451
452    /**
453     * @param array $data
454     * @param HTMLForm $form
455     * @param string $action
456     * @return bool
457     */
458    public function processTagForm( array $data, HTMLForm $form, string $action ) {
459        $context = $form->getContext();
460        $out = $context->getOutput();
461
462        $tag = $data['HiddenTag'];
463        // activateTagWithChecks, deactivateTagWithChecks, deleteTagWithChecks
464        $status = call_user_func( [ ChangeTags::class, "{$action}TagWithChecks" ],
465            $tag, $data['Reason'], $context->getUser(), true );
466
467        if ( $status->isGood() ) {
468            $out->redirect( $this->getPageTitle()->getLocalURL() );
469            return true;
470        } elseif ( $status->isOK() && $action === 'delete' ) {
471            // deletion succeeded, but hooks raised a warning
472            $out->addWikiTextAsInterface( $this->msg( 'tags-delete-warnings-after-delete', $tag,
473                count( $status->getWarningsArray() ) )->text() . "\n" .
474                $status->getWikitext() );
475            $out->addReturnTo( $this->getPageTitle() );
476            return true;
477        } else {
478            $out->wrapWikiTextAsInterface( 'error', $status->getWikitext() );
479            return false;
480        }
481    }
482
483    /**
484     * Return an array of subpages that this special page will accept.
485     *
486     * @return string[] subpages
487     */
488    public function getSubpagesForPrefixSearch() {
489        // The subpages does not have an own form, so not listing it at the moment
490        return [
491            // 'delete',
492            // 'activate',
493            // 'deactivate',
494            // 'create',
495        ];
496    }
497
498    protected function getGroupName() {
499        return 'changes';
500    }
501}
502
503/**
504 * Retain the old class name for backwards compatibility.
505 * @deprecated since 1.41
506 */
507class_alias( SpecialTags::class, 'SpecialTags' );