Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
159 / 159 |
|
100.00% |
8 / 8 |
CRAP | |
100.00% |
1 / 1 |
| ChangeTagsFormatter | |
100.00% |
159 / 159 |
|
100.00% |
8 / 8 |
40 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| formatTagsAsSummaryList | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
7 | |||
| getTagDescription | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| tagShortDescriptionMessage | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| tagHelpLink | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| buildTagFilter | |
100.00% |
47 / 47 |
|
100.00% |
1 / 1 |
7 | |||
| getChangeTagListSummary | |
100.00% |
42 / 42 |
|
100.00% |
1 / 1 |
8 | |||
| getChangeTagList | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
7 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\ChangeTags; |
| 8 | |
| 9 | use MediaWiki\Config\ServiceOptions; |
| 10 | use MediaWiki\Context\IContextSource; |
| 11 | use MediaWiki\Html\Html; |
| 12 | use MediaWiki\Language\LanguageFactory; |
| 13 | use MediaWiki\Language\LocalizationContext; |
| 14 | use MediaWiki\Language\MessageLocalizer; |
| 15 | use MediaWiki\Language\RawMessage; |
| 16 | use MediaWiki\MainConfigNames; |
| 17 | use MediaWiki\Message\Message; |
| 18 | use MediaWiki\Parser\Sanitizer; |
| 19 | use MediaWiki\Permissions\Authority; |
| 20 | use MediaWiki\Skin\Skin; |
| 21 | use OOUI\ComboBoxInputWidget; |
| 22 | use 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 | */ |
| 30 | class 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 | } |