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