Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.21% covered (danger)
15.21%
59 / 388
27.03% covered (danger)
27.03%
10 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangeTags
15.21% covered (danger)
15.21%
59 / 388
27.03% covered (danger)
27.03%
10 / 37
10274.46
0.00% covered (danger)
0.00%
0 / 1
 getSoftwareTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatSummaryRow
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
8
 tagShortDescriptionMessage
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 tagDescription
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 tagLongDescriptionMessage
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 addTags
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 updateTags
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getTagsWithData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 restrictedTagError
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 canAddTagsAccompanyingChange
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 canUpdateTags
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 updateTagsWithChecks
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
132
 modifyDisplayQuery
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getDisplayTableName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeTagSummarySubquery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildTagFilterSelector
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
42
 defineTag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canActivateTag
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 activateTagWithChecks
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 canDeactivateTag
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 deactivateTagWithChecks
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 isTagNameValid
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 canCreateTag
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 createTagWithChecks
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 deleteTagEverywhere
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canDeleteTag
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
132
 deleteTagWithChecks
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 listSoftwareActivatedTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 listDefinedTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 listExplicitlyDefinedTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listSoftwareDefinedTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 purgeTagCacheAll
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tagUsageStatistics
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getChangeTagListSummary
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
30
 getChangeTagList
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 showTagEditingUI
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Recent changes tagging.
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 Change tagging
22 */
23
24use MediaWiki\Context\IContextSource;
25use MediaWiki\Context\RequestContext;
26use MediaWiki\HookContainer\HookRunner;
27use MediaWiki\Html\Html;
28use MediaWiki\Language\RawMessage;
29use MediaWiki\MainConfigNames;
30use MediaWiki\MediaWikiServices;
31use MediaWiki\Message\Message;
32use MediaWiki\Parser\Sanitizer;
33use MediaWiki\Permissions\Authority;
34use MediaWiki\Permissions\PermissionStatus;
35use MediaWiki\SpecialPage\SpecialPage;
36use MediaWiki\Status\Status;
37use MediaWiki\Title\Title;
38use MediaWiki\User\UserIdentity;
39use MediaWiki\Xml\XmlSelect;
40use Wikimedia\Rdbms\IReadableDatabase;
41
42class ChangeTags {
43    /**
44     * The tagged edit changes the content model of the page.
45     */
46    public const TAG_CONTENT_MODEL_CHANGE = 'mw-contentmodelchange';
47    /**
48     * The tagged edit creates a new redirect (either by creating a new page or turning an
49     * existing page into a redirect).
50     */
51    public const TAG_NEW_REDIRECT = 'mw-new-redirect';
52    /**
53     * The tagged edit turns a redirect page into a non-redirect.
54     */
55    public const TAG_REMOVED_REDIRECT = 'mw-removed-redirect';
56    /**
57     * The tagged edit changes the target of a redirect page.
58     */
59    public const TAG_CHANGED_REDIRECT_TARGET = 'mw-changed-redirect-target';
60    /**
61     * The tagged edit blanks the page (replaces it with the empty string).
62     */
63    public const TAG_BLANK = 'mw-blank';
64    /**
65     * The tagged edit removes more than 90% of the content of the page.
66     */
67    public const TAG_REPLACE = 'mw-replace';
68    /**
69     * The tagged edit is a rollback (undoes the previous edit and all immediately preceding edits
70     * by the same user, and was performed via the "rollback" link available to advanced users
71     * or via the rollback API).
72     *
73     * The associated tag data is a JSON containing the edit result (see EditResult::jsonSerialize()).
74     */
75    public const TAG_ROLLBACK = 'mw-rollback';
76    /**
77     * The tagged edit is was performed via the "undo" link. (Usually this means that it undoes
78     * some previous edit, but the undo workflow includes an edit step so it could be anything.)
79     *
80     * The associated tag data is a JSON containing the edit result (see EditResult::jsonSerialize()).
81     */
82    public const TAG_UNDO = 'mw-undo';
83    /**
84     * The tagged edit restores the page to an earlier revision.
85     *
86     * The associated tag data is a JSON containing the edit result (see EditResult::jsonSerialize()).
87     */
88    public const TAG_MANUAL_REVERT = 'mw-manual-revert';
89    /**
90     * The tagged edit is reverted by a subsequent edit (which is tagged by one of TAG_ROLLBACK,
91     * TAG_UNDO, TAG_MANUAL_REVERT). Multiple edits might be reverted by the same edit.
92     *
93     * The associated tag data is a JSON containing the edit result (see EditResult::jsonSerialize())
94     * with an extra 'revertId' field containing the revision ID of the reverting edit.
95     */
96    public const TAG_REVERTED = 'mw-reverted';
97    /**
98     * This tagged edit was performed while importing media files using the importImages.php maintenance script.
99     */
100    public const TAG_SERVER_SIDE_UPLOAD = 'mw-server-side-upload';
101
102    /**
103     * List of tags which denote a revert of some sort. (See also TAG_REVERTED.)
104     */
105    public const REVERT_TAGS = [ self::TAG_ROLLBACK, self::TAG_UNDO, self::TAG_MANUAL_REVERT ];
106
107    /**
108     * Flag for canDeleteTag().
109     */
110    public const BYPASS_MAX_USAGE_CHECK = 1;
111
112    /**
113     * Can't delete tags with more than this many uses. Similar in intent to
114     * the bigdelete user right
115     * @todo Use the job queue for tag deletion to avoid this restriction
116     */
117    private const MAX_DELETE_USES = 5000;
118
119    /**
120     * Name of change_tag table
121     */
122    private const CHANGE_TAG = 'change_tag';
123
124    public const DISPLAY_TABLE_ALIAS = 'changetagdisplay';
125
126    /**
127     * Loads defined core tags, checks for invalid types (if not array),
128     * and filters for supported and enabled (if $all is false) tags only.
129     *
130     * @param bool $all If true, return all valid defined tags. Otherwise, return only enabled ones.
131     * @return array Array of all defined/enabled tags.
132     * @deprecated since 1.41 use ChangeTagsStore::getSoftwareTags() instead.
133     */
134    public static function getSoftwareTags( $all = false ) {
135        return MediaWikiServices::getInstance()->getChangeTagsStore()->getSoftwareTags( $all );
136    }
137
138    /**
139     * Creates HTML for the given tags
140     *
141     * @param string $tags Comma-separated list of tags
142     * @param null|string $unused Unused (formerly: $page)
143     * @param MessageLocalizer|null $localizer
144     * @note Even though it takes null as a valid argument, a MessageLocalizer is preferred
145     *       in a new code, as the null value is subject to change in the future
146     * @return array Array with two items: (html, classes)
147     *   - html: String: HTML for displaying the tags (empty string when param $tags is empty)
148     *   - classes: Array of strings: CSS classes used in the generated html, one class for each tag
149     * @return-taint onlysafefor_htmlnoent
150     */
151    public static function formatSummaryRow( $tags, $unused, MessageLocalizer $localizer = null ) {
152        if ( $tags === '' || $tags === null ) {
153            return [ '', [] ];
154        }
155        if ( !$localizer ) {
156            $localizer = RequestContext::getMain();
157        }
158
159        $classes = [];
160
161        $tags = explode( ',', $tags );
162        $order = array_flip( MediaWikiServices::getInstance()->getChangeTagsStore()->listDefinedTags() );
163        usort( $tags, static function ( $a, $b ) use ( $order ) {
164            return ( $order[ $a ] ?? INF ) <=> ( $order[ $b ] ?? INF );
165        } );
166
167        $displayTags = [];
168        foreach ( $tags as $tag ) {
169            if ( $tag === '' ) {
170                continue;
171            }
172            $classes[] = Sanitizer::escapeClass( "mw-tag-$tag" );
173            $description = self::tagDescription( $tag, $localizer );
174            if ( $description === false ) {
175                continue;
176            }
177            $displayTags[] = Html::rawElement(
178                'span',
179                [ 'class' => 'mw-tag-marker ' .
180                    Sanitizer::escapeClass( "mw-tag-marker-$tag" ) ],
181                $description
182            );
183        }
184
185        if ( !$displayTags ) {
186            return [ '', $classes ];
187        }
188
189        $markers = $localizer->msg( 'tag-list-wrapper' )
190            ->numParams( count( $displayTags ) )
191            ->rawParams( implode( ' ', $displayTags ) )
192            ->parse();
193        $markers = Html::rawElement( 'span', [ 'class' => 'mw-tag-markers' ], $markers );
194
195        return [ $markers, $classes ];
196    }
197
198    /**
199     * Get the message object for the tag's short description.
200     *
201     * Checks if message key "mediawiki:tag-$tag" exists. If it does not,
202     * returns the tag name in a RawMessage. If the message exists, it is
203     * used, provided it is not disabled. If the message is disabled, we
204     * consider the tag hidden, and return false.
205     *
206     * @since 1.34
207     * @param string $tag
208     * @param MessageLocalizer $context
209     * @return Message|false Tag description, or false if tag is to be hidden.
210     */
211    public static function tagShortDescriptionMessage( $tag, MessageLocalizer $context ) {
212        $msg = $context->msg( "tag-$tag" );
213        if ( !$msg->exists() ) {
214            // No such message
215            // Pass through ->msg(), even though it seems redundant, to avoid requesting
216            // the user's language from session-less entry points (T227233)
217            return $context->msg( new RawMessage( '$1', [ Message::plaintextParam( $tag ) ] ) );
218        }
219        if ( $msg->isDisabled() ) {
220            // The message exists but is disabled, hide the tag.
221            return false;
222        }
223
224        // Message exists and isn't disabled, use it.
225        return $msg;
226    }
227
228    /**
229     * Get a short description for a tag.
230     *
231     * Checks if message key "mediawiki:tag-$tag" exists. If it does not,
232     * returns the HTML-escaped tag name. Uses the message if the message
233     * exists, provided it is not disabled. If the message is disabled,
234     * we consider the tag hidden, and return false.
235     *
236     * @param string $tag
237     * @param MessageLocalizer $context
238     * @return string|false Tag description or false if tag is to be hidden.
239     * @since 1.25 Returns false if tag is to be hidden.
240     */
241    public static function tagDescription( $tag, MessageLocalizer $context ) {
242        $msg = self::tagShortDescriptionMessage( $tag, $context );
243        return $msg ? $msg->parse() : false;
244    }
245
246    /**
247     * Get the message object for the tag's long description.
248     *
249     * Checks if message key "mediawiki:tag-$tag-description" exists. If it does not,
250     * or if message is disabled, returns false. Otherwise, returns the message object
251     * for the long description.
252     *
253     * @param string $tag
254     * @param MessageLocalizer $context
255     * @return Message|false Message object of the tag long description or false if
256     *  there is no description.
257     */
258    public static function tagLongDescriptionMessage( $tag, MessageLocalizer $context ) {
259        $msg = $context->msg( "tag-$tag-description" );
260        if ( !$msg->exists() ) {
261            return false;
262        }
263        if ( $msg->isDisabled() ) {
264            // The message exists but is disabled, hide the description.
265            return false;
266        }
267
268        // Message exists and isn't disabled, use it.
269        return $msg;
270    }
271
272    /**
273     * Add tags to a change given its rc_id, rev_id and/or log_id
274     *
275     * @deprecated since 1.41 use ChangeTagsStore instead.
276     * @param string|string[] $tags Tags to add to the change
277     * @param int|null $rc_id The rc_id of the change to add the tags to
278     * @param int|null $rev_id The rev_id of the change to add the tags to
279     * @param int|null $log_id The log_id of the change to add the tags to
280     * @param string|null $params Params to put in the ct_params field of table 'change_tag'
281     * @param RecentChange|null $rc Recent change, in case the tagging accompanies the action
282     * (this should normally be the case)
283     *
284     * @return bool False if no changes are made, otherwise true
285     */
286    public static function addTags( $tags, $rc_id = null, $rev_id = null,
287        $log_id = null, $params = null, RecentChange $rc = null
288    ) {
289        return MediaWikiServices::getInstance()->getChangeTagsStore()->addTags(
290            $tags, $rc_id, $rev_id, $log_id, $params, $rc
291        );
292    }
293
294    /**
295     * Add and remove tags to/from a change given its rc_id, rev_id and/or log_id,
296     * without verifying that the tags exist or are valid. If a tag is present in
297     * both $tagsToAdd and $tagsToRemove, it will be removed.
298     *
299     * This function should only be used by extensions to manipulate tags they
300     * have registered using the ListDefinedTags hook. When dealing with user
301     * input, call updateTagsWithChecks() instead.
302     *
303     * @deprecated since 1.41 use ChangeTagStore::updateTags()
304     * @param string|array|null $tagsToAdd Tags to add to the change
305     * @param string|array|null $tagsToRemove Tags to remove from the change
306     * @param int|null &$rc_id The rc_id of the change to add the tags to.
307     * Pass a variable whose value is null if the rc_id is not relevant or unknown.
308     * @param int|null &$rev_id The rev_id of the change to add the tags to.
309     * Pass a variable whose value is null if the rev_id is not relevant or unknown.
310     * @param int|null &$log_id The log_id of the change to add the tags to.
311     * Pass a variable whose value is null if the log_id is not relevant or unknown.
312     * @param string|null $params Params to put in the ct_params field of table
313     * 'change_tag' when adding tags
314     * @param RecentChange|null $rc Recent change being tagged, in case the tagging accompanies
315     * the action
316     * @param UserIdentity|null $user Tagging user, in case the tagging is subsequent to the tagged action
317     *
318     * @return array Index 0 is an array of tags actually added, index 1 is an
319     * array of tags actually removed, index 2 is an array of tags present on the
320     * revision or log entry before any changes were made
321     *
322     * @since 1.25
323     */
324    public static function updateTags( $tagsToAdd, $tagsToRemove, &$rc_id = null,
325        &$rev_id = null, &$log_id = null, $params = null, RecentChange $rc = null,
326        UserIdentity $user = null
327    ) {
328        return MediaWikiServices::getInstance()->getChangeTagsStore()->updateTags(
329            $tagsToAdd, $tagsToRemove, $rc_id, $rev_id, $log_id, $params, $rc, $user
330        );
331    }
332
333    /**
334     * Return all the tags associated with the given recent change ID,
335     * revision ID, and/or log entry ID, along with any data stored with the tag.
336     *
337     * @deprecated since 1.41 use ChangeTagStore::getTagsWithData()
338     * @param IReadableDatabase $db the database to query
339     * @param int|null $rc_id
340     * @param int|null $rev_id
341     * @param int|null $log_id
342     * @return string[] Tag name => data. Data format is tag-specific.
343     * @since 1.36
344     */
345    public static function getTagsWithData(
346        IReadableDatabase $db, $rc_id = null, $rev_id = null, $log_id = null
347    ) {
348        return MediaWikiServices::getInstance()->getChangeTagsStore()->getTagsWithData( $db, $rc_id, $rev_id, $log_id );
349    }
350
351    /**
352     * Return all the tags associated with the given recent change ID,
353     * revision ID, and/or log entry ID.
354     *
355     * @deprecated since 1.41 use ChangeTagStore::getTags()
356     * @param IReadableDatabase $db the database to query
357     * @param int|null $rc_id
358     * @param int|null $rev_id
359     * @param int|null $log_id
360     * @return string[]
361     */
362    public static function getTags( IReadableDatabase $db, $rc_id = null, $rev_id = null, $log_id = null ) {
363        return MediaWikiServices::getInstance()->getChangeTagsStore()->getTags( $db, $rc_id, $rev_id, $log_id );
364    }
365
366    /**
367     * Helper function to generate a fatal status with a 'not-allowed' type error.
368     *
369     * @param string $msgOne Message key to use in the case of one tag
370     * @param string $msgMulti Message key to use in the case of more than one tag
371     * @param string[] $tags Restricted tags (passed as $1 into the message, count of
372     * $tags passed as $2)
373     * @return Status
374     * @since 1.25
375     */
376    protected static function restrictedTagError( $msgOne, $msgMulti, $tags ) {
377        $lang = RequestContext::getMain()->getLanguage();
378        $tags = array_values( $tags );
379        $count = count( $tags );
380        $status = Status::newFatal( ( $count > 1 ) ? $msgMulti : $msgOne,
381            $lang->commaList( $tags ), $count );
382        $status->value = $tags;
383        return $status;
384    }
385
386    /**
387     * Is it OK to allow the user to apply all the specified tags at the same time
388     * as they edit/make the change?
389     *
390     * Extensions should not use this function, unless directly handling a user
391     * request to add a tag to a revision or log entry that the user is making.
392     *
393     * @param string[] $tags Tags that you are interested in applying
394     * @param Authority|null $performer whose permission you wish to check, or null to
395     * check for a generic non-blocked user with the relevant rights
396     * @param bool $checkBlock Whether to check the blocked status of $performer
397     * @return Status
398     * @since 1.25
399     */
400    public static function canAddTagsAccompanyingChange(
401        array $tags,
402        Authority $performer = null,
403        $checkBlock = true
404    ) {
405        $user = null;
406        $services = MediaWikiServices::getInstance();
407        if ( $performer !== null ) {
408            if ( !$performer->isAllowed( 'applychangetags' ) ) {
409                return Status::newFatal( 'tags-apply-no-permission' );
410            }
411
412            if ( $checkBlock && $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
413                return Status::newFatal(
414                    'tags-apply-blocked',
415                    $performer->getUser()->getName()
416                );
417            }
418
419            // ChangeTagsAllowedAdd hook still needs a full User object
420            $user = $services->getUserFactory()->newFromAuthority( $performer );
421        }
422
423        // to be applied, a tag has to be explicitly defined
424        $allowedTags = $services->getChangeTagsStore()->listExplicitlyDefinedTags();
425        ( new HookRunner( $services->getHookContainer() ) )->onChangeTagsAllowedAdd( $allowedTags, $tags, $user );
426        $disallowedTags = array_diff( $tags, $allowedTags );
427        if ( $disallowedTags ) {
428            return self::restrictedTagError( 'tags-apply-not-allowed-one',
429                'tags-apply-not-allowed-multi', $disallowedTags );
430        }
431
432        return Status::newGood();
433    }
434
435    /**
436     * Is it OK to allow the user to adds and remove the given tags to/from a
437     * change?
438     *
439     * Extensions should not use this function, unless directly handling a user
440     * request to add or remove tags from an existing revision or log entry.
441     *
442     * @param string[] $tagsToAdd Tags that you are interested in adding
443     * @param string[] $tagsToRemove Tags that you are interested in removing
444     * @param Authority|null $performer whose permission you wish to check, or null to
445     * check for a generic non-blocked user with the relevant rights
446     * @return Status
447     * @since 1.25
448     */
449    public static function canUpdateTags(
450        array $tagsToAdd,
451        array $tagsToRemove,
452        Authority $performer = null
453    ) {
454        if ( $performer !== null ) {
455            if ( !$performer->isDefinitelyAllowed( 'changetags' ) ) {
456                return Status::newFatal( 'tags-update-no-permission' );
457            }
458
459            if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
460                return Status::newFatal(
461                    'tags-update-blocked',
462                    $performer->getUser()->getName()
463                );
464            }
465        }
466
467        $changeTagStore = MediaWikiServices::getInstance()->getChangeTagsStore();
468        if ( $tagsToAdd ) {
469            // to be added, a tag has to be explicitly defined
470            // @todo Allow extensions to define tags that can be applied by users...
471            $explicitlyDefinedTags = $changeTagStore->listExplicitlyDefinedTags();
472            $diff = array_diff( $tagsToAdd, $explicitlyDefinedTags );
473            if ( $diff ) {
474                return self::restrictedTagError( 'tags-update-add-not-allowed-one',
475                    'tags-update-add-not-allowed-multi', $diff );
476            }
477        }
478
479        if ( $tagsToRemove ) {
480            // to be removed, a tag must not be defined by an extension, or equivalently it
481            // has to be either explicitly defined or not defined at all
482            // (assuming no edge case of a tag both explicitly-defined and extension-defined)
483            $softwareDefinedTags = $changeTagStore->listSoftwareDefinedTags();
484            $intersect = array_intersect( $tagsToRemove, $softwareDefinedTags );
485            if ( $intersect ) {
486                return self::restrictedTagError( 'tags-update-remove-not-allowed-one',
487                    'tags-update-remove-not-allowed-multi', $intersect );
488            }
489        }
490
491        return Status::newGood();
492    }
493
494    /**
495     * Adds and/or removes tags to/from a given change, checking whether it is
496     * allowed first, and adding a log entry afterwards.
497     *
498     * Includes a call to ChangeTags::canUpdateTags(), so your code doesn't need
499     * to do that. However, it doesn't check whether the *_id parameters are a
500     * valid combination. That is up to you to enforce. See ApiTag::execute() for
501     * an example.
502     *
503     * Extensions should generally avoid this function. Call
504     * ChangeTags::updateTags() instead, unless directly handling a user request
505     * to add or remove tags from an existing revision or log entry.
506     *
507     * @param array|null $tagsToAdd If none, pass [] or null
508     * @param array|null $tagsToRemove If none, pass [] or null
509     * @param int|null $rc_id The rc_id of the change to add the tags to
510     * @param int|null $rev_id The rev_id of the change to add the tags to
511     * @param int|null $log_id The log_id of the change to add the tags to
512     * @param string|null $params Params to put in the ct_params field of table
513     * 'change_tag' when adding tags
514     * @param string $reason Comment for the log
515     * @param Authority $performer who to check permissions and give credit for the action
516     * @return Status If successful, the value of this Status object will be an
517     * object (stdClass) with the following fields:
518     *  - logId: the ID of the added log entry, or null if no log entry was added
519     *    (i.e. no operation was performed)
520     *  - addedTags: an array containing the tags that were actually added
521     *  - removedTags: an array containing the tags that were actually removed
522     * @since 1.25
523     */
524    public static function updateTagsWithChecks( $tagsToAdd, $tagsToRemove,
525        $rc_id, $rev_id, $log_id, $params, string $reason, Authority $performer
526    ) {
527        if ( !$tagsToAdd && !$tagsToRemove ) {
528            // no-op, don't bother
529            return Status::newGood( (object)[
530                'logId' => null,
531                'addedTags' => [],
532                'removedTags' => [],
533            ] );
534        }
535
536        $tagsToAdd ??= [];
537        $tagsToRemove ??= [];
538
539        // are we allowed to do this?
540        $result = self::canUpdateTags( $tagsToAdd, $tagsToRemove, $performer );
541        if ( !$result->isOK() ) {
542            $result->value = null;
543            return $result;
544        }
545
546        // basic rate limiting
547        $status = PermissionStatus::newEmpty();
548        if ( !$performer->authorizeAction( 'changetags', $status ) ) {
549            return Status::wrap( $status );
550        }
551
552        // do it!
553        $changeTagStore = MediaWikiServices::getInstance()->getChangeTagsStore();
554        [ $tagsAdded, $tagsRemoved, $initialTags ] = $changeTagStore->updateTags( $tagsToAdd,
555            $tagsToRemove, $rc_id, $rev_id, $log_id, $params, null, $performer->getUser() );
556        if ( !$tagsAdded && !$tagsRemoved ) {
557            // no-op, don't log it
558            return Status::newGood( (object)[
559                'logId' => null,
560                'addedTags' => [],
561                'removedTags' => [],
562            ] );
563        }
564
565        // log it
566        $logEntry = new ManualLogEntry( 'tag', 'update' );
567        $logEntry->setPerformer( $performer->getUser() );
568        $logEntry->setComment( $reason );
569
570        // find the appropriate target page
571        if ( $rev_id ) {
572            $revisionRecord = MediaWikiServices::getInstance()
573                ->getRevisionLookup()
574                ->getRevisionById( $rev_id );
575            if ( $revisionRecord ) {
576                $logEntry->setTarget( $revisionRecord->getPageAsLinkTarget() );
577            }
578        } elseif ( $log_id ) {
579            // This function is from revision deletion logic and has nothing to do with
580            // change tags, but it appears to be the only other place in core where we
581            // perform logged actions on log items.
582            $logEntry->setTarget( RevDelLogList::suggestTarget( null, [ $log_id ] ) );
583        }
584
585        if ( !$logEntry->getTarget() ) {
586            // target is required, so we have to set something
587            $logEntry->setTarget( SpecialPage::getTitleFor( 'Tags' ) );
588        }
589
590        $logParams = [
591            '4::revid' => $rev_id,
592            '5::logid' => $log_id,
593            '6:list:tagsAdded' => $tagsAdded,
594            '7:number:tagsAddedCount' => count( $tagsAdded ),
595            '8:list:tagsRemoved' => $tagsRemoved,
596            '9:number:tagsRemovedCount' => count( $tagsRemoved ),
597            'initialTags' => $initialTags,
598        ];
599        $logEntry->setParameters( $logParams );
600        $logEntry->setRelations( [ 'Tag' => array_merge( $tagsAdded, $tagsRemoved ) ] );
601
602        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
603        $logId = $logEntry->insert( $dbw );
604        // Only send this to UDP, not RC, similar to patrol events
605        $logEntry->publish( $logId, 'udp' );
606
607        return Status::newGood( (object)[
608            'logId' => $logId,
609            'addedTags' => $tagsAdded,
610            'removedTags' => $tagsRemoved,
611        ] );
612    }
613
614    /**
615     * Applies all tags-related changes to a query.
616     * Handles selecting tags, and filtering.
617     * Needs $tables to be set up properly, so we can figure out which join conditions to use.
618     *
619     * WARNING: If $filter_tag contains more than one tag and $exclude is false, this function
620     * will add DISTINCT, which may cause performance problems for your query unless you put
621     * the ID field of your table at the end of the ORDER BY, and set a GROUP BY equal to the
622     * ORDER BY. For example, if you had ORDER BY foo_timestamp DESC, you will now need
623     * GROUP BY foo_timestamp, foo_id ORDER BY foo_timestamp DESC, foo_id DESC.
624     *
625     * @deprecated since 1.41 use ChangeTagsStore::modifyDisplayQueryBuilder instead
626     * @param string|array &$tables Table names, see Database::select
627     * @param string|array &$fields Fields used in query, see Database::select
628     * @param string|array &$conds Conditions used in query, see Database::select
629     * @param array &$join_conds Join conditions, see Database::select
630     * @param string|array &$options Options, see Database::select
631     * @param string|array|false|null $filter_tag Tag(s) to select on (OR)
632     * @param bool $exclude If true, exclude tag(s) from $filter_tag (NOR)
633     *
634     */
635    public static function modifyDisplayQuery( &$tables, &$fields, &$conds,
636        &$join_conds, &$options, $filter_tag = '', bool $exclude = false
637    ) {
638        MediaWikiServices::getInstance()->getChangeTagsStore()->modifyDisplayQuery(
639            $tables,
640            $fields,
641            $conds,
642            $join_conds,
643            $options,
644            $filter_tag,
645            $exclude
646        );
647    }
648
649    /**
650     * Get the name of the change_tag table to use for modifyDisplayQuery().
651     * This also does first-call initialisation of the table in testing mode.
652     *
653     * @deprecated since 1.41 use ChangeTags::CHANGE_TAG or 'change_tag' instead.
654     *   Note that directly querying this table is discouraged, try using one of
655     *   the existing functions instead.
656     * @return string
657     */
658    public static function getDisplayTableName() {
659        return self::CHANGE_TAG;
660    }
661
662    /**
663     * Make the tag summary subquery based on the given tables and return it.
664     *
665     * @deprecated since 1.41 use ChangeTagStore instead
666     * @param string|array $tables Table names, see Database::select
667     *
668     * @return string tag summary subqeury
669     */
670    public static function makeTagSummarySubquery( $tables ) {
671        return MediaWikiServices::getInstance()->getChangeTagsStore()->makeTagSummarySubquery( $tables );
672    }
673
674    /**
675     * Build a text box to select a change tag
676     *
677     * @param string $selected Tag to select by default
678     * @param bool $ooui Use an OOUI TextInputWidget as selector instead of a non-OOUI input field
679     *        You need to call OutputPage::enableOOUI() yourself.
680     * @param IContextSource|null $context
681     * @note Even though it takes null as a valid argument, an IContextSource is preferred
682     *       in a new code, as the null value can change in the future
683     * @return array an array of (label, selector)
684     */
685    public static function buildTagFilterSelector(
686        $selected = '', $ooui = false, IContextSource $context = null
687    ) {
688        if ( !$context ) {
689            $context = RequestContext::getMain();
690        }
691
692        $config = $context->getConfig();
693        $changeTagStore = MediaWikiServices::getInstance()->getChangeTagsStore();
694        if ( !$config->get( MainConfigNames::UseTagFilter ) ||
695        !count( $changeTagStore->listDefinedTags() ) ) {
696            return [];
697        }
698
699        $tags = self::getChangeTagList( $context, $context->getLanguage() );
700        $autocomplete = [];
701        foreach ( $tags as $tagInfo ) {
702            $autocomplete[ $tagInfo['label'] ] = $tagInfo['name'];
703        }
704
705        $data = [
706            Html::rawElement(
707                'label',
708                [ 'for' => 'tagfilter' ],
709                $context->msg( 'tag-filter' )->parse()
710            )
711        ];
712
713        if ( $ooui ) {
714            $options = Html::listDropdownOptionsOoui( $autocomplete );
715
716            $data[] = new OOUI\ComboBoxInputWidget( [
717                'id' => 'tagfilter',
718                'name' => 'tagfilter',
719                'value' => $selected,
720                'classes' => 'mw-tagfilter-input',
721                'options' => $options,
722            ] );
723        } else {
724            $datalist = new XmlSelect( false, 'tagfilter-datalist' );
725            $datalist->setTagName( 'datalist' );
726            $datalist->addOptions( $autocomplete );
727
728            $data[] = Html::input(
729                'tagfilter',
730                $selected,
731                'text',
732                [
733                    'class' => [ 'mw-tagfilter-input', 'mw-ui-input', 'mw-ui-input-inline' ],
734                    'size' => 20,
735                    'id' => 'tagfilter',
736                    'list' => 'tagfilter-datalist',
737                ]
738            ) . $datalist->getHTML();
739        }
740
741        return $data;
742    }
743
744    /**
745     * Set ctd_user_defined = 1 in change_tag_def without checking that the tag name is valid.
746     * Extensions should NOT use this function; they can use the ListDefinedTags
747     * hook instead.
748     *
749     * @deprecated since 1.41 use ChangeTagsStore
750     * @param string $tag Tag to create
751     * @since 1.25
752     */
753    public static function defineTag( $tag ) {
754        MediaWikiServices::getInstance()->getChangeTagsStore()->defineTag( $tag );
755    }
756
757    /**
758     * Is it OK to allow the user to activate this tag?
759     *
760     * @param string $tag Tag that you are interested in activating
761     * @param Authority|null $performer whose permission you wish to check, or null if
762     * you don't care (e.g. maintenance scripts)
763     * @return Status
764     * @since 1.25
765     */
766    public static function canActivateTag( $tag, Authority $performer = null ) {
767        if ( $performer !== null ) {
768            if ( !$performer->isAllowed( 'managechangetags' ) ) {
769                return Status::newFatal( 'tags-manage-no-permission' );
770            }
771            if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
772                return Status::newFatal(
773                    'tags-manage-blocked',
774                    $performer->getUser()->getName()
775                );
776            }
777        }
778
779        // defined tags cannot be activated (a defined tag is either extension-
780        // defined, in which case the extension chooses whether or not to active it;
781        // or user-defined, in which case it is considered active)
782        $changeTagStore = MediaWikiServices::getInstance()->getChangeTagsStore();
783        $definedTags = $changeTagStore->listDefinedTags();
784        if ( in_array( $tag, $definedTags ) ) {
785            return Status::newFatal( 'tags-activate-not-allowed', $tag );
786        }
787
788        // non-existing tags cannot be activated
789        if ( !isset( $changeTagStore->tagUsageStatistics()[$tag] ) ) { // we already know the tag is undefined
790            return Status::newFatal( 'tags-activate-not-found', $tag );
791        }
792
793        return Status::newGood();
794    }
795
796    /**
797     * Activates a tag, checking whether it is allowed first, and adding a log
798     * entry afterwards.
799     *
800     * Includes a call to ChangeTag::canActivateTag(), so your code doesn't need
801     * to do that.
802     *
803     * @param string $tag
804     * @param string $reason
805     * @param Authority $performer who to check permissions and give credit for the action
806     * @param bool $ignoreWarnings Can be used for API interaction, default false
807     * @param array $logEntryTags Change tags to apply to the entry
808     * that will be created in the tag management log
809     * @return Status If successful, the Status contains the ID of the added log
810     * entry as its value
811     * @since 1.25
812     */
813    public static function activateTagWithChecks( string $tag, string $reason, Authority $performer,
814        bool $ignoreWarnings = false, array $logEntryTags = []
815    ) {
816        // are we allowed to do this?
817        $result = self::canActivateTag( $tag, $performer );
818        if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
819            $result->value = null;
820            return $result;
821        }
822        $changeTagStore = MediaWikiServices::getInstance()->getChangeTagsStore();
823
824        $changeTagStore->defineTag( $tag );
825
826        $logId = $changeTagStore->logTagManagementAction( 'activate', $tag, $reason, $performer->getUser(),
827            null, $logEntryTags );
828
829        return Status::newGood( $logId );
830    }
831
832    /**
833     * Is it OK to allow the user to deactivate this tag?
834     *
835     * @param string $tag Tag that you are interested in deactivating
836     * @param Authority|null $performer whose permission you wish to check, or null if
837     * you don't care (e.g. maintenance scripts)
838     * @return Status
839     * @since 1.25
840     */
841    public static function canDeactivateTag( $tag, Authority $performer = null ) {
842        if ( $performer !== null ) {
843            if ( !$performer->isAllowed( 'managechangetags' ) ) {
844                return Status::newFatal( 'tags-manage-no-permission' );
845            }
846            if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
847                return Status::newFatal(
848                    'tags-manage-blocked',
849                    $performer->getUser()->getName()
850                );
851            }
852        }
853
854        // only explicitly-defined tags can be deactivated
855        $explicitlyDefinedTags = MediaWikiServices::getInstance()->getChangeTagsStore()->listExplicitlyDefinedTags();
856        if ( !in_array( $tag, $explicitlyDefinedTags ) ) {
857            return Status::newFatal( 'tags-deactivate-not-allowed', $tag );
858        }
859        return Status::newGood();
860    }
861
862    /**
863     * Deactivates a tag, checking whether it is allowed first, and adding a log
864     * entry afterwards.
865     *
866     * Includes a call to ChangeTag::canDeactivateTag(), so your code doesn't need
867     * to do that.
868     *
869     * @param string $tag
870     * @param string $reason
871     * @param Authority $performer who to check permissions and give credit for the action
872     * @param bool $ignoreWarnings Can be used for API interaction, default false
873     * @param array $logEntryTags Change tags to apply to the entry
874     * that will be created in the tag management log
875     * @return Status If successful, the Status contains the ID of the added log
876     * entry as its value
877     * @since 1.25
878     */
879    public static function deactivateTagWithChecks( string $tag, string $reason, Authority $performer,
880        bool $ignoreWarnings = false, array $logEntryTags = []
881    ) {
882        // are we allowed to do this?
883        $result = self::canDeactivateTag( $tag, $performer );
884        if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
885            $result->value = null;
886            return $result;
887        }
888        $changeTagStore = MediaWikiServices::getInstance()->getChangeTagsStore();
889
890        $changeTagStore->undefineTag( $tag );
891
892        $logId = $changeTagStore->logTagManagementAction( 'deactivate', $tag, $reason,
893            $performer->getUser(), null, $logEntryTags );
894
895        return Status::newGood( $logId );
896    }
897
898    /**
899     * Is the tag name valid?
900     *
901     * @param string $tag Tag that you are interested in creating
902     * @return Status
903     * @since 1.30
904     */
905    public static function isTagNameValid( $tag ) {
906        // no empty tags
907        if ( $tag === '' ) {
908            return Status::newFatal( 'tags-create-no-name' );
909        }
910
911        // tags cannot contain commas (used to be used as a delimiter in tag_summary table),
912        // pipe (used as a delimiter between multiple tags in
913        // SpecialRecentchanges and friends), or slashes (would break tag description messages in
914        // MediaWiki namespace)
915        if ( strpos( $tag, ',' ) !== false || strpos( $tag, '|' ) !== false
916            || strpos( $tag, '/' ) !== false ) {
917            return Status::newFatal( 'tags-create-invalid-chars' );
918        }
919
920        // could the MediaWiki namespace description messages be created?
921        $title = Title::makeTitleSafe( NS_MEDIAWIKI, "Tag-$tag-description" );
922        if ( $title === null ) {
923            return Status::newFatal( 'tags-create-invalid-title-chars' );
924        }
925
926        return Status::newGood();
927    }
928
929    /**
930     * Is it OK to allow the user to create this tag?
931     *
932     * Extensions should NOT use this function. In most cases, a tag can be
933     * defined using the ListDefinedTags hook without any checking.
934     *
935     * @param string $tag Tag that you are interested in creating
936     * @param Authority|null $performer whose permission you wish to check, or null if
937     * you don't care (e.g. maintenance scripts)
938     * @return Status
939     * @since 1.25
940     */
941    public static function canCreateTag( $tag, Authority $performer = null ) {
942        $user = null;
943        $services = MediaWikiServices::getInstance();
944        if ( $performer !== null ) {
945            if ( !$performer->isAllowed( 'managechangetags' ) ) {
946                return Status::newFatal( 'tags-manage-no-permission' );
947            }
948            if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
949                return Status::newFatal(
950                    'tags-manage-blocked',
951                    $performer->getUser()->getName()
952                );
953            }
954            // ChangeTagCanCreate hook still needs a full User object
955            $user = $services->getUserFactory()->newFromAuthority( $performer );
956        }
957
958        $status = self::isTagNameValid( $tag );
959        if ( !$status->isGood() ) {
960            return $status;
961        }
962
963        // does the tag already exist?
964        $changeTagStore = $services->getChangeTagsStore();
965        if (
966            isset( $changeTagStore->tagUsageStatistics()[$tag] ) ||
967            in_array( $tag, $changeTagStore->listDefinedTags() )
968        ) {
969            return Status::newFatal( 'tags-create-already-exists', $tag );
970        }
971
972        // check with hooks
973        $canCreateResult = Status::newGood();
974        ( new HookRunner( $services->getHookContainer() ) )->onChangeTagCanCreate( $tag, $user, $canCreateResult );
975        return $canCreateResult;
976    }
977
978    /**
979     * Creates a tag by adding it to `change_tag_def` table.
980     *
981     * Extensions should NOT use this function; they can use the ListDefinedTags
982     * hook instead.
983     *
984     * Includes a call to ChangeTag::canCreateTag(), so your code doesn't need to
985     * do that.
986     *
987     * @param string $tag
988     * @param string $reason
989     * @param Authority $performer who to check permissions and give credit for the action
990     * @param bool $ignoreWarnings Can be used for API interaction, default false
991     * @param array $logEntryTags Change tags to apply to the entry
992     * that will be created in the tag management log
993     * @return Status If successful, the Status contains the ID of the added log
994     * entry as its value
995     * @since 1.25
996     */
997    public static function createTagWithChecks( string $tag, string $reason, Authority $performer,
998        bool $ignoreWarnings = false, array $logEntryTags = []
999    ) {
1000        // are we allowed to do this?
1001        $result = self::canCreateTag( $tag, $performer );
1002        if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
1003            $result->value = null;
1004            return $result;
1005        }
1006
1007        $changeTagStore = MediaWikiServices::getInstance()->getChangeTagsStore();
1008        $changeTagStore->defineTag( $tag );
1009        $logId = $changeTagStore->logTagManagementAction( 'create', $tag, $reason,
1010            $performer->getUser(), null, $logEntryTags );
1011
1012        return Status::newGood( $logId );
1013    }
1014
1015    /**
1016     * Permanently removes all traces of a tag from the DB. Good for removing
1017     * misspelt or temporary tags.
1018     *
1019     * This function should be directly called by maintenance scripts only, never
1020     * by user-facing code. See deleteTagWithChecks() for functionality that can
1021     * safely be exposed to users.
1022     *
1023     * @deprecated since 1.41 use ChangeTagsStore instead
1024     * @param string $tag Tag to remove
1025     * @return Status The returned status will be good unless a hook changed it
1026     * @since 1.25
1027     */
1028    public static function deleteTagEverywhere( $tag ) {
1029        return MediaWikiServices::getInstance()->getChangeTagsStore()->deleteTagEverywhere( $tag );
1030    }
1031
1032    /**
1033     * Is it OK to allow the user to delete this tag?
1034     *
1035     * @param string $tag Tag that you are interested in deleting
1036     * @param Authority|null $performer whose permission you wish to check, or null if
1037     * you don't care (e.g. maintenance scripts)
1038     * @param int $flags Use ChangeTags::BYPASS_MAX_USAGE_CHECK to ignore whether
1039     *  there are more uses than we would normally allow to be deleted through the
1040     *  user interface.
1041     * @return Status
1042     * @since 1.25
1043     */
1044    public static function canDeleteTag( $tag, Authority $performer = null, int $flags = 0 ) {
1045        $user = null;
1046        $services = MediaWikiServices::getInstance();
1047        if ( $performer !== null ) {
1048            if ( !$performer->isAllowed( 'deletechangetags' ) ) {
1049                return Status::newFatal( 'tags-delete-no-permission' );
1050            }
1051            if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
1052                return Status::newFatal(
1053                    'tags-manage-blocked',
1054                    $performer->getUser()->getName()
1055                );
1056            }
1057            // ChangeTagCanDelete hook still needs a full User object
1058            $user = $services->getUserFactory()->newFromAuthority( $performer );
1059        }
1060
1061        $changeTagStore = $services->getChangeTagsStore();
1062        $tagUsage = $changeTagStore->tagUsageStatistics();
1063        if (
1064            !isset( $tagUsage[$tag] ) &&
1065            !in_array( $tag, $changeTagStore->listDefinedTags() )
1066        ) {
1067            return Status::newFatal( 'tags-delete-not-found', $tag );
1068        }
1069
1070        if ( $flags !== self::BYPASS_MAX_USAGE_CHECK &&
1071            isset( $tagUsage[$tag] ) &&
1072            $tagUsage[$tag] > self::MAX_DELETE_USES
1073        ) {
1074            return Status::newFatal( 'tags-delete-too-many-uses', $tag, self::MAX_DELETE_USES );
1075        }
1076
1077        $softwareDefined = $changeTagStore->listSoftwareDefinedTags();
1078        if ( in_array( $tag, $softwareDefined ) ) {
1079            // extension-defined tags can't be deleted unless the extension
1080            // specifically allows it
1081            $status = Status::newFatal( 'tags-delete-not-allowed' );
1082        } else {
1083            // user-defined tags are deletable unless otherwise specified
1084            $status = Status::newGood();
1085        }
1086
1087        ( new HookRunner( $services->getHookContainer() ) )->onChangeTagCanDelete( $tag, $user, $status );
1088        return $status;
1089    }
1090
1091    /**
1092     * Deletes a tag, checking whether it is allowed first, and adding a log entry
1093     * afterwards.
1094     *
1095     * Includes a call to ChangeTag::canDeleteTag(), so your code doesn't need to
1096     * do that.
1097     *
1098     * @param string $tag
1099     * @param string $reason
1100     * @param Authority $performer who to check permissions and give credit for the action
1101     * @param bool $ignoreWarnings Can be used for API interaction, default false
1102     * @param array $logEntryTags Change tags to apply to the entry
1103     * that will be created in the tag management log
1104     * @return Status If successful, the Status contains the ID of the added log
1105     * entry as its value
1106     * @since 1.25
1107     */
1108    public static function deleteTagWithChecks( string $tag, string $reason, Authority $performer,
1109        bool $ignoreWarnings = false, array $logEntryTags = []
1110    ) {
1111        $changeTagStore = MediaWikiServices::getInstance()->getChangeTagsStore();
1112        // are we allowed to do this?
1113        $result = self::canDeleteTag( $tag, $performer );
1114        if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
1115            $result->value = null;
1116            return $result;
1117        }
1118
1119        // store the tag usage statistics
1120        $hitcount = $changeTagStore->tagUsageStatistics()[$tag] ?? 0;
1121
1122        // do it!
1123        $deleteResult = $changeTagStore->deleteTagEverywhere( $tag );
1124        if ( !$deleteResult->isOK() ) {
1125            return $deleteResult;
1126        }
1127
1128        // log it
1129        $changeTagStore = MediaWikiServices::getInstance()->getChangeTagsStore();
1130        $logId = $changeTagStore->logTagManagementAction( 'delete', $tag, $reason, $performer->getUser(),
1131            $hitcount, $logEntryTags );
1132
1133        $deleteResult->value = $logId;
1134        return $deleteResult;
1135    }
1136
1137    /**
1138     * Lists those tags which core or extensions report as being "active".
1139     *
1140     * @deprecated since 1.41 use ChangeTagsStore instead
1141     * @return array
1142     * @since 1.25
1143     */
1144    public static function listSoftwareActivatedTags() {
1145        return MediaWikiServices::getInstance()->getChangeTagsStore()->listSoftwareActivatedTags();
1146    }
1147
1148    /**
1149     * Basically lists defined tags which count even if they aren't applied to anything.
1150     * It returns a union of the results of listExplicitlyDefinedTags() and
1151     * listSoftwareDefinedTags()
1152     *
1153     * @deprecated since 1.41 use ChangeTagsStore instead
1154     * @return string[] Array of strings: tags
1155     */
1156    public static function listDefinedTags() {
1157        return MediaWikiServices::getInstance()->getChangeTagsStore()->listDefinedTags();
1158    }
1159
1160    /**
1161     * Lists tags explicitly defined in the `change_tag_def` table of the database.
1162     *
1163     * Tries memcached first.
1164     *
1165     * @deprecated since 1.41 use ChangeTagsStore instead
1166     * @return string[] Array of strings: tags
1167     * @since 1.25
1168     */
1169    public static function listExplicitlyDefinedTags() {
1170        return MediaWikiServices::getInstance()->getChangeTagsStore()->listExplicitlyDefinedTags();
1171    }
1172
1173    /**
1174     * Lists tags defined by core or extensions using the ListDefinedTags hook.
1175     * Extensions need only define those tags they deem to be in active use.
1176     *
1177     * Tries memcached first.
1178     *
1179     * @deprecated since 1.41 use ChangeTagsStore instead
1180     * @return string[] Array of strings: tags
1181     * @since 1.25
1182     */
1183    public static function listSoftwareDefinedTags() {
1184        return MediaWikiServices::getInstance()->getChangeTagsStore()->listSoftwareDefinedTags();
1185    }
1186
1187    /**
1188     * Invalidates the short-term cache of defined tags used by the
1189     * list*DefinedTags functions, as well as the tag statistics cache.
1190     * @deprecated since 1.41 use ChangeTagsStore instead
1191     * @since 1.25
1192     */
1193    public static function purgeTagCacheAll() {
1194        MediaWikiServices::getInstance()->getChangeTagsStore()->purgeTagCacheAll();
1195    }
1196
1197    /**
1198     * Returns a map of any tags used on the wiki to number of edits
1199     * tagged with them, ordered descending by the hitcount.
1200     * This does not include tags defined somewhere that have never been applied.
1201     *
1202     * @deprecated since 1.41 use ChangeTagsStore
1203     * @return array Array of string => int
1204     */
1205    public static function tagUsageStatistics() {
1206        return MediaWikiServices::getInstance()->getChangeTagsStore()->tagUsageStatistics();
1207    }
1208
1209    /**
1210     * Maximum length of a tag description in UTF-8 characters.
1211     * Longer descriptions will be truncated.
1212     */
1213    private const TAG_DESC_CHARACTER_LIMIT = 120;
1214
1215    /**
1216     * Get information about change tags, without parsing messages, for tag filter dropdown menus.
1217     *
1218     * Message contents are the raw values (->plain()), because parsing messages is expensive.
1219     * Even though we're not parsing messages, building a data structure with the contents of
1220     * hundreds of i18n messages is still not cheap (see T223260#5370610), so this function
1221     * caches its output in WANCache for up to 24 hours.
1222     *
1223     * Returns an array of associative arrays with information about each tag:
1224     * - name: Tag name (string)
1225     * - labelMsg: Short description message (Message object, or false for hidden tags)
1226     * - label: Short description message (raw message contents)
1227     * - descriptionMsg: Long description message (Message object)
1228     * - description: Long description message (raw message contents)
1229     * - cssClass: CSS class to use for RC entries with this tag
1230     * - hits: Number of RC entries that have this tag
1231     *
1232     * This data is consumed by the `mediawiki.rcfilters.filters.ui` module,
1233     * specifically `mw.rcfilters.dm.FilterGroup` and `mw.rcfilters.dm.FilterItem`.
1234     *
1235     * @param MessageLocalizer $localizer
1236     * @param Language $lang
1237     * @return array[] Information about each tag
1238     */
1239    public static function getChangeTagListSummary( MessageLocalizer $localizer, Language $lang ) {
1240        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1241        return $cache->getWithSetCallback(
1242            $cache->makeKey( 'tags-list-summary', $lang->getCode() ),
1243            WANObjectCache::TTL_DAY,
1244            static function ( $oldValue, &$ttl, array &$setOpts ) use ( $localizer ) {
1245                $changeTagStore = MediaWikiServices::getInstance()->getChangeTagsStore();
1246                $tagHitCounts = $changeTagStore->tagUsageStatistics();
1247
1248                $result = [];
1249                // Only list tags that are still actively defined
1250                foreach ( $changeTagStore->listDefinedTags() as $tagName ) {
1251                    // Only list tags with more than 0 hits
1252                    $hits = $tagHitCounts[$tagName] ?? 0;
1253                    if ( $hits <= 0 ) {
1254                        continue;
1255                    }
1256
1257                    $labelMsg = self::tagShortDescriptionMessage( $tagName, $localizer );
1258                    $descriptionMsg = self::tagLongDescriptionMessage( $tagName, $localizer );
1259                    // Don't cache the message object, use the correct MessageLocalizer to parse later.
1260                    $result[] = [
1261                        'name' => $tagName,
1262                        'labelMsg' => (bool)$labelMsg,
1263                        'label' => $labelMsg ? $labelMsg->plain() : $tagName,
1264                        'descriptionMsg' => (bool)$descriptionMsg,
1265                        'description' => $descriptionMsg ? $descriptionMsg->plain() : '',
1266                        'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
1267                    ];
1268                }
1269                return $result;
1270            }
1271        );
1272    }
1273
1274    /**
1275     * Get information about change tags for tag filter dropdown menus.
1276     *
1277     * This manipulates the label and description of each tag, which are parsed, stripped
1278     * and (in the case of description) truncated versions of these messages. Message
1279     * parsing is expensive, so to detect whether the tag list has changed, use
1280     * getChangeTagListSummary() instead.
1281     *
1282     * @param MessageLocalizer $localizer
1283     * @param Language $lang
1284     * @return array[] Same as getChangeTagListSummary(), with messages parsed, stripped and truncated
1285     */
1286    public static function getChangeTagList( MessageLocalizer $localizer, Language $lang ) {
1287        $tags = self::getChangeTagListSummary( $localizer, $lang );
1288        foreach ( $tags as &$tagInfo ) {
1289            if ( $tagInfo['labelMsg'] ) {
1290                // Use localizer with the correct page title to parse plain message from the cache.
1291                $labelMsg = new RawMessage( $tagInfo['label'] );
1292                $tagInfo['label'] = Sanitizer::stripAllTags( $localizer->msg( $labelMsg )->parse() );
1293            } else {
1294                $tagInfo['label'] = $localizer->msg( 'tag-hidden', $tagInfo['name'] )->text();
1295            }
1296            if ( $tagInfo['descriptionMsg'] ) {
1297                $descriptionMsg = new RawMessage( $tagInfo['description'] );
1298                $tagInfo['description'] = $lang->truncateForVisual(
1299                    Sanitizer::stripAllTags( $localizer->msg( $descriptionMsg )->parse() ),
1300                    self::TAG_DESC_CHARACTER_LIMIT
1301                );
1302            }
1303            unset( $tagInfo['labelMsg'] );
1304            unset( $tagInfo['descriptionMsg'] );
1305        }
1306
1307        // Instead of sorting by hit count (disabled for now), sort by display name
1308        usort( $tags, static function ( $a, $b ) {
1309            return strcasecmp( $a['label'], $b['label'] );
1310        } );
1311        return $tags;
1312    }
1313
1314    /**
1315     * Indicate whether change tag editing UI is relevant
1316     *
1317     * Returns true if the user has the necessary right and there are any
1318     * editable tags defined.
1319     *
1320     * This intentionally doesn't check "any addable || any deletable", because
1321     * it seems like it would be more confusing than useful if the checkboxes
1322     * suddenly showed up because some abuse filter stopped defining a tag and
1323     * then suddenly disappeared when someone deleted all uses of that tag.
1324     *
1325     * @param Authority $performer
1326     * @return bool
1327     */
1328    public static function showTagEditingUI( Authority $performer ) {
1329        $changeTagStore = MediaWikiServices::getInstance()->getChangeTagsStore();
1330        return $performer->isAllowed( 'changetags' ) && (bool)$changeTagStore->listExplicitlyDefinedTags();
1331    }
1332}