Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
159 / 159
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
ChangeTagsFormatter
100.00% covered (success)
100.00%
159 / 159
100.00% covered (success)
100.00%
8 / 8
40
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatTagsAsSummaryList
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
7
 getTagDescription
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 tagShortDescriptionMessage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 tagHelpLink
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 buildTagFilter
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
7
 getChangeTagListSummary
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
8
 getChangeTagList
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\ChangeTags;
8
9use MediaWiki\Config\ServiceOptions;
10use MediaWiki\Context\IContextSource;
11use MediaWiki\Html\Html;
12use MediaWiki\Language\LanguageFactory;
13use MediaWiki\Language\LocalizationContext;
14use MediaWiki\Language\MessageLocalizer;
15use MediaWiki\Language\RawMessage;
16use MediaWiki\MainConfigNames;
17use MediaWiki\Message\Message;
18use MediaWiki\Parser\Sanitizer;
19use MediaWiki\Permissions\Authority;
20use MediaWiki\Skin\Skin;
21use OOUI\ComboBoxInputWidget;
22use Wikimedia\ObjectCache\WANObjectCache;
23
24/**
25 * Formats change tags for display in HTML and use filter dropdown menus.
26 *
27 * @since 1.47
28 * @ingroup ChangeTags
29 */
30class ChangeTagsFormatter {
31
32    /** @internal Only for use by ServiceWiring.php */
33    public const array CONSTRUCTOR_OPTIONS = [
34        MainConfigNames::UseTagFilter,
35    ];
36
37    /**
38     * Maximum length of a tag description in UTF-8 characters.
39     * Longer descriptions will be truncated.
40     */
41    private const int TAG_DESC_CHARACTER_LIMIT = 120;
42
43    public function __construct(
44        private readonly ServiceOptions $options,
45        private readonly ChangeTagsStore $changeTagsStore,
46        private readonly WANObjectCache $cache,
47        private readonly LanguageFactory $languageFactory,
48    ) {
49        $this->options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
50    }
51
52    /**
53     * Formats the provided tags into HTML for display to a user.
54     *
55     * @since 1.47
56     * @param string|null $tags Comma-separated list of tags (as returned by a database query)
57     * @param MessageLocalizer $localizer
58     * @param Authority $authority
59     * @return array{0:string,1:string[]} Array with two items: (html, classes)
60     *   - html: String: HTML for displaying the tags (empty string when param $tags is empty or
61     *       all tags have no description)
62     *   - classes: Array of strings: CSS classes to be appended to the parent element that this HTML
63     *       is appended to, one class per tag provided
64     * @return-taint onlysafefor_htmlnoent
65     */
66    public function formatTagsAsSummaryList(
67        ?string $tags,
68        MessageLocalizer $localizer,
69        Authority $authority
70    ): array {
71        if ( $tags === '' || $tags === null ) {
72            return [ '', [] ];
73        }
74
75        $classes = [];
76
77        $tags = explode( ',', $tags );
78        $tags = $this->changeTagsStore->filterViewableTags( $tags, $authority );
79        $order = array_flip( $this->changeTagsStore->listDefinedTags() );
80        usort( $tags, static function ( $a, $b ) use ( $order ) {
81            return ( $order[ $a ] ?? INF ) <=> ( $order[ $b ] ?? INF );
82        } );
83
84        $displayTags = [];
85        foreach ( $tags as $tag ) {
86            if ( $tag === '' ) {
87                continue;
88            }
89            $classes[] = Sanitizer::escapeClass( "mw-tag-$tag" );
90            $description = $this->getTagDescription( $tag, $localizer );
91            if ( $description === '' ) {
92                continue;
93            }
94            $displayTags[] = Html::rawElement(
95                'span',
96                [ 'class' => 'mw-tag-marker ' . Sanitizer::escapeClass( "mw-tag-marker-$tag" ) ],
97                $description
98            );
99        }
100
101        if ( !$displayTags ) {
102            return [ '', $classes ];
103        }
104
105        $markers = $localizer->msg( 'tag-list-wrapper' )
106            ->numParams( count( $displayTags ) )
107            ->rawParams( implode( ' ', $displayTags ) )
108            ->parse();
109        $markers = Html::rawElement( 'span', [ 'class' => 'mw-tag-markers' ], $markers );
110
111        return [ $markers, $classes ];
112    }
113
114    /**
115     * Get a (short) description for a tag. The description includes the label for the tag along with
116     * a help link if defined. If the tag description is an empty string, the tag is considered hidden.
117     *
118     * @since 1.47
119     */
120    public function getTagDescription( string $tag, MessageLocalizer $localizer ): string {
121        $msg = $this->tagShortDescriptionMessage( $tag, $localizer );
122        $link = $this->tagHelpLink( $tag, $localizer );
123        if ( !$msg->isDisabled() && $link ) {
124            $label = $msg->parse();
125            // Avoid invalid HTML caused by link wrapping if the label already contains a link
126            if ( !str_contains( $label, '<a ' ) ) {
127                return Html::rawElement( 'a', [ 'href' => $link ], $label );
128            }
129        }
130        return !$msg->isDisabled() ? $msg->parse() : '';
131    }
132
133    /**
134     * Get the {@link Message} for the tag's short description.
135     */
136    private function tagShortDescriptionMessage( string $tag, MessageLocalizer $messageLocalizer ): Message {
137        $msg = $messageLocalizer->msg( "tag-$tag" );
138        if ( !$msg->exists() ) {
139            // No such message
140            // Pass through ->msg(), even though it seems redundant, to avoid requesting
141            // the user's language from session-less entry points (T227233)
142            return $messageLocalizer->msg( new RawMessage( '$1', [ Message::plaintextParam( $tag ) ] ) );
143        }
144
145        return $msg;
146    }
147
148    /**
149     * Get the tag's help link, or `null` if no help link could be generated.
150     */
151    private function tagHelpLink( string $tag, MessageLocalizer $context ): ?string {
152        $msg = $context->msg( "tag-$tag-helppage" )->inContentLanguage();
153        if ( !$msg->isDisabled() ) {
154            return Skin::makeInternalOrExternalUrl( $msg->text() ) ?: null;
155        }
156        return null;
157    }
158
159    /**
160     * Build a text box to select a change tag. The tag set can be customized via the $activeOnly
161     * and $useAllTags parameters, and defaults to all active tags.
162     *
163     * @since 1.47
164     * @param string $selected Tag to select by default
165     * @param string $format One of the following formats:
166     *   - `'ooui'`: Use an OOUI {@link ComboBoxInputWidget}. You need to call {@link OutputPage::enableOOUI()}
167     *       yourself.
168     *   - `'codex'`: Use a Codex CSS-only Lookup input.
169     *   - otherwise: Plain HTML select box.
170     * @param IContextSource $context
171     * @param bool $activeOnly If `true`, only show tags which have been used at least once
172     * @param bool $useAllTags If `true`, use all known tags. If `false`, use only tags defined by MediaWiki core
173     *   (excluding tags defined by extensions, users, or site config)
174     * @return array{0:string,1:string|ComboBoxInputWidget}|null Two chunks of HTML (label, and dropdown menu)
175     *   or null if disabled
176     */
177    public function buildTagFilter(
178        string $selected,
179        string $format,
180        IContextSource $context,
181        bool $activeOnly = true,
182        bool $useAllTags = true
183    ): ?array {
184        if (
185            !$this->options->get( MainConfigNames::UseTagFilter ) ||
186            !count( $this->changeTagsStore->listDefinedTags() )
187        ) {
188            return null;
189        }
190
191        $tags = $this->getChangeTagList(
192            $context,
193            $context->getAuthority(),
194            $activeOnly,
195            $useAllTags,
196            true
197        );
198
199        $autocomplete = [];
200        foreach ( $tags as $tagInfo ) {
201            $autocomplete[ $tagInfo['label'] ] = $tagInfo['name'];
202        }
203
204        $data = [];
205        $data[0] = Html::rawElement(
206            'label',
207            [ 'for' => 'tagfilter' ],
208            $context->msg( 'tag-filter' )->parse()
209        );
210
211        if ( $format === 'ooui' ) {
212            $options = Html::listDropdownOptionsOoui( $autocomplete );
213
214            $data[1] = new ComboBoxInputWidget( [
215                'id' => 'tagfilter',
216                'name' => 'tagfilter',
217                'value' => $selected,
218                'classes' => 'mw-tagfilter-input',
219                'options' => $options,
220            ] );
221        } else {
222            $optionsHtml = '';
223            foreach ( $autocomplete as $label => $name ) {
224                $optionsHtml .= Html::element( 'option', [ 'value' => $name ], $label );
225            }
226            $datalistHtml = Html::rawElement( 'datalist', [ 'id' => 'tagfilter-datalist' ], $optionsHtml );
227
228            $data[1] = Html::input(
229                'tagfilter',
230                $selected,
231                'text',
232                [
233                    'class' => [ 'mw-tagfilter-input', 'cdx-text-input__input' => $format === 'codex' ],
234                    'size' => 20,
235                    'id' => 'tagfilter',
236                    'list' => 'tagfilter-datalist',
237                ]
238            );
239            if ( $format === 'codex' ) {
240                $data[1] = Html::rawElement( 'div', [ 'class' => 'cdx-text-input' ], $data[1] );
241            }
242            $data[1] .= $datalistHtml;
243        }
244
245        return $data;
246    }
247
248    /**
249     * Get information about change tags, without parsing messages, for tag filter dropdown menus.
250     * By default, this will return explicitly-defined and software-defined tags that are currently active (have hits)
251     *
252     * Message contents are the raw values (->plain()), because parsing messages is expensive.
253     * Even though we're not parsing messages, building a data structure with the contents of
254     * hundreds of i18n messages is still not cheap (see T223260#5370610), so this function
255     * caches its output in WANCache for up to 24 hours.
256     *
257     * Returns an array of associative arrays with information about each tag:
258     * - name: Tag name (string)
259     * - labelMsg: Whether the short description message exists and is enabled (boolean)
260     * - label: Short description message (raw message contents), or the tag name if
261     *     the short description message was disabled or did not exist
262     * - descriptionMsg: Whether the long description message exists and is enabled (boolean)
263     * - description: Long description message (raw message contents)
264     * - cssClass: CSS class to use for RC entries with this tag
265     * - helpLink: Link to a help page describing this tag (string or null)
266     *
267     * This data is consumed by the `mediawiki.rcfilters.filters.ui` module,
268     * specifically `mw.rcfilters.dm.FilterGroup` and `mw.rcfilters.dm.FilterItem`.
269     *
270     * @since 1.47
271     * @param LocalizationContext $localizationContext
272     * @param Authority $authority
273     * @param bool $activeOnly If `true`, only show tags which have been used at least once
274     * @param bool $useAllTags If `true`, use all known tags. If `false`, use only tags defined by MediaWiki core
275     *   (excluding tags defined by extensions, users, or site config)
276     * @return array[] Information about each tag
277     */
278    public function getChangeTagListSummary(
279        LocalizationContext $localizationContext,
280        Authority $authority,
281        bool $activeOnly = true,
282        bool $useAllTags = true
283    ): array {
284        if ( $useAllTags ) {
285            $tagKeys = $this->changeTagsStore->listDefinedTags();
286            $cacheKey = 'tags-list-summary';
287        } else {
288            $tagKeys = $this->changeTagsStore->getCoreDefinedTags();
289            $cacheKey = 'core-software-tags-summary';
290        }
291
292        // if $tagHitCounts exists, check against it later to determine whether or not to omit tags
293        $tagHitCounts = null;
294        if ( $activeOnly ) {
295            $tagHitCounts = $this->changeTagsStore->tagUsageStatistics();
296        } else {
297            // The full set of tags should use a different cache key than the subset
298            $cacheKey .= '-all';
299        }
300
301        $summary = $this->cache->getWithSetCallback(
302            $this->cache->makeKey( $cacheKey, strtolower( $localizationContext->getLanguageCode()->toBcp47Code() ) ),
303            WANObjectCache::TTL_DAY,
304            function () use ( $localizationContext, $tagKeys, $tagHitCounts ) {
305                $result = [];
306                foreach ( $tagKeys as $tagName ) {
307                    // Only list tags that are still actively defined
308                    if ( $tagHitCounts !== null ) {
309                        // Only list tags with more than 0 hits
310                        $hits = $tagHitCounts[$tagName] ?? 0;
311                        if ( $hits <= 0 ) {
312                            continue;
313                        }
314                    }
315
316                    $labelMsg = $this->tagShortDescriptionMessage( $tagName, $localizationContext );
317                    $helpLink = $this->tagHelpLink( $tagName, $localizationContext );
318                    $descriptionMsg = $localizationContext->msg( "tag-$tagName-description" );
319                    // Don't cache the message object, use the correct MessageLocalizer to parse later.
320                    $result[] = [
321                        'name' => $tagName,
322                        'labelMsg' => !$labelMsg->isDisabled(),
323                        'label' => !$labelMsg->isDisabled() ? $labelMsg->plain() : $tagName,
324                        'descriptionMsg' => !$descriptionMsg->isDisabled(),
325                        'description' => !$descriptionMsg->isDisabled() ? $descriptionMsg->plain() : '',
326                        'helpLink' => $helpLink,
327                        'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
328                    ];
329                }
330                return $result;
331            }
332        );
333
334        // Filter out tags that the user cannot see before returning this (the cache assumes the user can see all tags
335        // to avoid splitting it by user)
336        $viewable = array_fill_keys(
337            $this->changeTagsStore->filterViewableTags( array_column( $summary, 'name' ), $authority ),
338            true
339        );
340        return array_values( array_filter(
341            $summary,
342            static fn ( array $tagInfo ) => isset( $viewable[ $tagInfo['name'] ] )
343        ) );
344    }
345
346    /**
347     * Get information about change tags for tag filter dropdown menus.
348     *
349     * This manipulates the label and description of each tag, which are parsed, stripped
350     * and (in the case of description) truncated versions of these messages. Message
351     * parsing is expensive, so to detect whether the tag list has changed, use
352     * {@link ChangeTagsFormatter::getChangeTagListSummary()} instead.
353     *
354     * @since 1.47
355     * @param LocalizationContext $localizationContext
356     * @param Authority $authority
357     * @param bool $activeOnly If `true`, only show tags which have been used at least once
358     * @param bool $useAllTags If `true`, use all known tags. If `false`, use only tags defined by MediaWiki core
359     *   (excluding tags defined by extensions, users, or site config)
360     * @param bool $labelsOnly Do not parse descriptions and omit 'description' in the result
361     * @return array[] Same as {@link ChangeTagsFormatter::getChangeTagListSummary()}, with messages parsed,
362     *   stripped and truncated
363     */
364    public function getChangeTagList(
365        LocalizationContext $localizationContext,
366        Authority $authority,
367        bool $activeOnly = true,
368        bool $useAllTags = true,
369        bool $labelsOnly = false
370    ): array {
371        $tags = $this->getChangeTagListSummary( $localizationContext, $authority, $activeOnly, $useAllTags );
372
373        $language = $this->languageFactory->getLanguage( $localizationContext->getLanguageCode() );
374        foreach ( $tags as &$tagInfo ) {
375            if ( $tagInfo['labelMsg'] ) {
376                // Optimization: Skip the parsing if the label contains only plain text (T344352)
377                if ( wfEscapeWikiText( $tagInfo['label'] ) !== $tagInfo['label'] ) {
378                    // Use localizer with the correct page title to parse plain message from the cache.
379                    $labelMsg = new RawMessage( $tagInfo['label'] );
380                    $tagInfo['label'] = Sanitizer::stripAllTags( $localizationContext->msg( $labelMsg )->parse() );
381                }
382            } else {
383                $tagInfo['label'] = $localizationContext->msg( 'tag-hidden', $tagInfo['name'] )->text();
384            }
385            // Optimization: Skip parsing the descriptions if not needed by the caller (T344352)
386            if ( $labelsOnly ) {
387                unset( $tagInfo['description'] );
388            } elseif ( $tagInfo['descriptionMsg'] ) {
389                // Optimization: Skip the parsing if the description contains only plain text (T344352)
390                if ( wfEscapeWikiText( $tagInfo['description'] ) !== $tagInfo['description'] ) {
391                    $descriptionMsg = new RawMessage( $tagInfo['description'] );
392                    $tagInfo['description'] = Sanitizer::stripAllTags(
393                        $localizationContext->msg( $descriptionMsg )->parse()
394                    );
395                }
396                $tagInfo['description'] = $language->truncateForVisual( $tagInfo['description'],
397                    self::TAG_DESC_CHARACTER_LIMIT );
398            }
399            unset( $tagInfo['labelMsg'] );
400            unset( $tagInfo['descriptionMsg'] );
401        }
402
403        // Instead of sorting by hit count (disabled for now), sort by display name
404        usort( $tags, static function ( $a, $b ) {
405            return strcasecmp( $a['label'], $b['label'] );
406        } );
407        return $tags;
408    }
409}