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