Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
15.34% |
27 / 176 |
|
3.70% |
1 / 27 |
CRAP | |
0.00% |
0 / 1 |
Utilities | |
15.43% |
27 / 175 |
|
3.70% |
1 / 27 |
2237.58 | |
0.00% |
0 / 1 |
title | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
figureMessage | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getMessageContent | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getContents | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
4 | |||
getContentForTitle | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
42 | |||
getLanguageName | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
languageSelector | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
getLanguageSelector | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
getLanguageNames | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
messageKeyToGroups | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
normaliseKey | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
fieldset | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
convertWhiteSpaceToHTML | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
cacheFile | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
getPlaceholder | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getIcon | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
30 | |||
getSafeReadDB | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
shouldReadFromPrimary | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
getEditorUrl | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
serialize | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
deserialize | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getVersion | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
allowsSubpages | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
isSupportedLanguageCode | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getTextFromTextContent | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getTranslations | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
6 | |||
isTranslationPage | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
42 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\Utilities; |
5 | |
6 | use ConfigException; |
7 | use Content; |
8 | use LanguageCode; |
9 | use MediaWiki\Extension\Translate\MessageLoading\MessageHandle; |
10 | use MediaWiki\Extension\Translate\PageTranslation\Hooks as PageTranslationHooks; |
11 | use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage; |
12 | use MediaWiki\Extension\Translate\Services; |
13 | use MediaWiki\Languages\LanguageNameUtils; |
14 | use MediaWiki\MediaWikiServices; |
15 | use MediaWiki\Revision\RevisionRecord; |
16 | use MediaWiki\Revision\SlotRecord; |
17 | use MediaWiki\Title\Title; |
18 | use MediaWiki\Title\TitleValue; |
19 | use MessageGroup; |
20 | use RequestContext; |
21 | use TextContent; |
22 | use UnexpectedValueException; |
23 | use Wikimedia\Rdbms\IDatabase; |
24 | use Xml; |
25 | use XmlSelect; |
26 | |
27 | /** |
28 | * Essentially random collection of helper functions, similar to GlobalFunctions.php. |
29 | * @author Niklas Laxström |
30 | * @license GPL-2.0-or-later |
31 | */ |
32 | class Utilities { |
33 | /** |
34 | * Does quick normalisation of message name so that in can be looked from the |
35 | * database. |
36 | * @param string $message Name of the message |
37 | * @param string $code Language code in lower case and with dash as delimiter |
38 | * @param int $ns Namespace constant |
39 | * @return string The normalised title as a string. |
40 | */ |
41 | public static function title( string $message, string $code, int $ns = NS_MEDIAWIKI ): string { |
42 | // Cache some amount of titles for speed. |
43 | static $cache = []; |
44 | $key = $ns . ':' . $message; |
45 | |
46 | if ( !isset( $cache[$key] ) ) { |
47 | $cache[$key] = Title::capitalize( $message, $ns ); |
48 | } |
49 | |
50 | if ( $code ) { |
51 | return $cache[$key] . '/' . $code; |
52 | } else { |
53 | return $cache[$key]; |
54 | } |
55 | } |
56 | |
57 | /** |
58 | * Splits page name into message key and language code. |
59 | * @param string $text |
60 | * @return array ( string, string ) Key and language code. |
61 | * @todo Handle names without slash. |
62 | */ |
63 | public static function figureMessage( string $text ): array { |
64 | $pos = strrpos( $text, '/' ); |
65 | $code = substr( $text, $pos + 1 ); |
66 | $key = substr( $text, 0, $pos ); |
67 | |
68 | return [ $key, $code ]; |
69 | } |
70 | |
71 | /** |
72 | * Loads page content *without* side effects. |
73 | * @param string $key Message key. |
74 | * @param string $language Language code. |
75 | * @param int $namespace Namespace number. |
76 | * @return string|null The contents or null. |
77 | */ |
78 | public static function getMessageContent( string $key, string $language, int $namespace = NS_MEDIAWIKI ): ?string { |
79 | $title = self::title( $key, $language, $namespace ); |
80 | $data = self::getContents( [ $title ], $namespace ); |
81 | |
82 | return $data[$title][0] ?? null; |
83 | } |
84 | |
85 | /** |
86 | * Fetches contents for pagenames in given namespace without side effects. |
87 | * |
88 | * @param string|string[] $titles Database page names. |
89 | * @param int $namespace The number of the namespace. |
90 | * @return array ( string => array ( string, string ) ) Tuples of page |
91 | * text and last author indexed by page name. |
92 | */ |
93 | public static function getContents( $titles, int $namespace ): array { |
94 | $mwServices = MediaWikiServices::getInstance(); |
95 | $dbr = $mwServices->getDBLoadBalancer()->getConnection( DB_REPLICA ); |
96 | $revStore = $mwServices->getRevisionStore(); |
97 | $titleContents = []; |
98 | |
99 | $query = $revStore->getQueryInfo( [ 'page', 'user' ] ); |
100 | $rows = $dbr->newSelectQueryBuilder() |
101 | ->select( $query['fields'] ) |
102 | ->tables( $query['tables'] ) |
103 | ->joinConds( $query['joins'] ) |
104 | ->where( [ 'page_namespace' => $namespace, 'page_title' => $titles, 'page_latest=rev_id' ] ) |
105 | ->caller( __METHOD__ ) |
106 | ->fetchResultSet(); |
107 | |
108 | $revisions = $revStore->newRevisionsFromBatch( $rows, [ |
109 | 'slots' => true, |
110 | 'content' => true |
111 | ] )->getValue(); |
112 | |
113 | foreach ( $rows as $row ) { |
114 | /** @var RevisionRecord|null $rev */ |
115 | $rev = $revisions[$row->rev_id]; |
116 | if ( $rev ) { |
117 | /** @var TextContent $content */ |
118 | $content = $rev->getContent( SlotRecord::MAIN ); |
119 | if ( $content ) { |
120 | $titleContents[$row->page_title] = [ |
121 | $content->getText(), |
122 | $row->rev_user_text |
123 | ]; |
124 | } |
125 | } |
126 | } |
127 | |
128 | $rows->free(); |
129 | |
130 | return $titleContents; |
131 | } |
132 | |
133 | /** |
134 | * Returns the content for a given title and adds the fuzzy tag if requested. |
135 | * @param Title $title |
136 | * @param bool $addFuzzy Add the fuzzy tag if appropriate. |
137 | * @return string|null |
138 | */ |
139 | public static function getContentForTitle( Title $title, bool $addFuzzy = false ): ?string { |
140 | $store = MediaWikiServices::getInstance()->getRevisionStore(); |
141 | $revision = $store->getRevisionByTitle( $title ); |
142 | |
143 | if ( $revision === null ) { |
144 | return null; |
145 | } |
146 | |
147 | $content = $revision->getContent( SlotRecord::MAIN ); |
148 | $wiki = ( $content instanceof TextContent ) ? $content->getText() : null; |
149 | |
150 | // Either unexpected content type, or the revision content is hidden |
151 | if ( $wiki === null ) { |
152 | return null; |
153 | } |
154 | |
155 | if ( $addFuzzy ) { |
156 | $handle = new MessageHandle( $title ); |
157 | if ( $handle->isFuzzy() ) { |
158 | $wiki = TRANSLATE_FUZZY . str_replace( TRANSLATE_FUZZY, '', $wiki ); |
159 | } |
160 | } |
161 | |
162 | return $wiki; |
163 | } |
164 | |
165 | /* Some other helpers for output */ |
166 | |
167 | /** |
168 | * Returns a localised language name. |
169 | * @param string $code Language code. |
170 | * @param null|string $language Language code of the language that the name should be in. |
171 | * @return string Best-effort localisation of wanted language name. |
172 | */ |
173 | public static function getLanguageName( string $code, ?string $language = 'en' ): string { |
174 | $languages = self::getLanguageNames( $language ); |
175 | return $languages[$code] ?? $code; |
176 | } |
177 | |
178 | // TODO remove languageSelector() after Sunsetting of TranslateSvg extension |
179 | |
180 | /** |
181 | * Returns a language selector. |
182 | * @param string $language Language code of the language the names should be localised to. |
183 | * @param string $selectedId The language code that is selected by default. |
184 | * @return string |
185 | */ |
186 | public static function languageSelector( $language, $selectedId ) { |
187 | $selector = self::getLanguageSelector( $language ); |
188 | $selector->setDefault( $selectedId ); |
189 | $selector->setAttribute( 'id', 'language' ); |
190 | $selector->setAttribute( 'name', 'language' ); |
191 | |
192 | return $selector->getHTML(); |
193 | } |
194 | |
195 | /** |
196 | * Standard language selector in Translate extension. |
197 | * @param string $language Language code of the language the names should be localised to. |
198 | * @param ?string $labelOption |
199 | * @return XmlSelect |
200 | */ |
201 | public static function getLanguageSelector( $language, ?string $labelOption = null ) { |
202 | $languages = self::getLanguageNames( $language ); |
203 | ksort( $languages ); |
204 | |
205 | $selector = new XmlSelect(); |
206 | if ( $labelOption !== null ) { |
207 | $selector->addOption( $labelOption, '-' ); |
208 | } |
209 | |
210 | foreach ( $languages as $code => $name ) { |
211 | $selector->addOption( "$code - $name", $code ); |
212 | } |
213 | |
214 | return $selector; |
215 | } |
216 | |
217 | /** |
218 | * Get translated language names for the languages generally supported for |
219 | * translation in the current wiki. Message groups can have further |
220 | * exclusions. |
221 | * @param null|string $code |
222 | * @return array ( language code => language name ) |
223 | */ |
224 | public static function getLanguageNames( ?string $code ): array { |
225 | $mwServices = MediaWikiServices::getInstance(); |
226 | $languageNames = $mwServices->getLanguageNameUtils()->getLanguageNames( $code ); |
227 | |
228 | $deprecatedCodes = LanguageCode::getDeprecatedCodeMapping(); |
229 | foreach ( array_keys( $deprecatedCodes ) as $deprecatedCode ) { |
230 | unset( $languageNames[ $deprecatedCode ] ); |
231 | } |
232 | Services::getInstance()->getHookRunner()->onTranslateSupportedLanguages( $languageNames, $code ); |
233 | |
234 | return $languageNames; |
235 | } |
236 | |
237 | /** |
238 | * Returns all the groups the message belongs to. |
239 | * @return string[] Possibly empty list of group ids. |
240 | */ |
241 | public static function messageKeyToGroups( int $namespace, string $key ): array { |
242 | $title = new TitleValue( $namespace, $key ); |
243 | $handle = new MessageHandle( $title ); |
244 | return $handle->getGroupIds(); |
245 | } |
246 | |
247 | /** Converts page name and namespace to message index format. */ |
248 | public static function normaliseKey( int $namespace, string $key ): string { |
249 | $key = lcfirst( $key ); |
250 | |
251 | return strtr( "$namespace:$key", ' ', '_' ); |
252 | } |
253 | |
254 | /** |
255 | * Constructs a fieldset with contents. |
256 | * @param string $legend Raw html. |
257 | * @param string $contents Raw html. |
258 | * @param array $attributes Html attributes for the fieldset. |
259 | * @return string Html. |
260 | */ |
261 | public static function fieldset( string $legend, string $contents, array $attributes = [] ): string { |
262 | return Xml::openElement( 'fieldset', $attributes ) . |
263 | Xml::tags( 'legend', null, $legend ) . $contents . |
264 | Xml::closeElement( 'fieldset' ); |
265 | } |
266 | |
267 | /** |
268 | * Escapes the message, and does some mangling to whitespace, so that it is |
269 | * preserved when outputted as-is to html page. Line feeds are converted to |
270 | * \<br /> and occurrences of leading and trailing and multiple consecutive |
271 | * spaces to non-breaking spaces. |
272 | * |
273 | * This is also implemented in JavaScript in ext.translate.quickedit. |
274 | * |
275 | * @param string $message Plain text string. |
276 | * @return string Text string that is ready for outputting. |
277 | */ |
278 | public static function convertWhiteSpaceToHTML( string $message ): string { |
279 | $msg = htmlspecialchars( $message ); |
280 | $msg = preg_replace( '/^ /m', ' ', $msg ); |
281 | $msg = preg_replace( '/ $/m', ' ', $msg ); |
282 | $msg = preg_replace( '/ /', '  ', $msg ); |
283 | $msg = str_replace( "\n", '<br />', $msg ); |
284 | |
285 | return $msg; |
286 | } |
287 | |
288 | /** |
289 | * Gets the path for cache files. The cache directory must be configured to use this method. |
290 | * @param string $filename |
291 | * @return string Full path. |
292 | */ |
293 | public static function cacheFile( string $filename ): string { |
294 | global $wgTranslateCacheDirectory, $wgCacheDirectory; |
295 | |
296 | if ( $wgTranslateCacheDirectory !== false ) { |
297 | $dir = $wgTranslateCacheDirectory; |
298 | } elseif ( $wgCacheDirectory !== false ) { |
299 | $dir = $wgCacheDirectory; |
300 | } else { |
301 | throw new ConfigException( "\$wgCacheDirectory must be configured" ); |
302 | } |
303 | |
304 | return "$dir/$filename"; |
305 | } |
306 | |
307 | /** Returns a random string that can be used as placeholder in strings. */ |
308 | public static function getPlaceholder(): string { |
309 | static $i = 0; |
310 | |
311 | return "\x7fUNIQ" . dechex( mt_rand( 0, 0x7fffffff ) ) . |
312 | dechex( mt_rand( 0, 0x7fffffff ) ) . '-' . $i++; |
313 | } |
314 | |
315 | /** |
316 | * Get URLs for icons if available. |
317 | * @param MessageGroup $g |
318 | * @param int $size Length of the edge of a bounding box to fit the icon. |
319 | * @return null|array |
320 | */ |
321 | public static function getIcon( MessageGroup $g, int $size ): ?array { |
322 | $icon = $g->getIcon(); |
323 | if ( !$icon || substr( $icon, 0, 7 ) !== 'wiki://' ) { |
324 | return null; |
325 | } |
326 | |
327 | $formats = []; |
328 | |
329 | $filename = substr( $icon, 7 ); |
330 | $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $filename ); |
331 | if ( !$file ) { |
332 | wfWarn( "Unknown message group icon file $icon" ); |
333 | |
334 | return null; |
335 | } |
336 | |
337 | if ( $file->isVectorized() ) { |
338 | $formats['vector'] = $file->getFullUrl(); |
339 | } |
340 | |
341 | $formats['raster'] = $file->createThumb( $size, $size ); |
342 | |
343 | return $formats; |
344 | } |
345 | |
346 | /** |
347 | * Get a DB handle suitable for read and read-for-write cases |
348 | * |
349 | * @return IDatabase Primary for HTTP POST, CLI, DB already changed; |
350 | * replica otherwise |
351 | */ |
352 | public static function getSafeReadDB(): IDatabase { |
353 | $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); |
354 | $index = self::shouldReadFromPrimary() ? DB_PRIMARY : DB_REPLICA; |
355 | |
356 | return $lb->getConnection( $index ); |
357 | } |
358 | |
359 | /** Check whether primary should be used for reads to avoid reading stale data. */ |
360 | public static function shouldReadFromPrimary(): bool { |
361 | $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); |
362 | // Parsing APIs need POST for payloads but are read-only, so avoid spamming |
363 | // the primary then. No good way to check this at the moment... |
364 | if ( PageTranslationHooks::$renderingContext ) { |
365 | return false; |
366 | } |
367 | |
368 | return PHP_SAPI === 'cli' || |
369 | RequestContext::getMain()->getRequest()->wasPosted() || |
370 | $lb->hasOrMadeRecentPrimaryChanges(); |
371 | } |
372 | |
373 | /** |
374 | * Get a URL that points to an editor for this message handle. |
375 | * @param MessageHandle $handle |
376 | * @return string Domain relative URL |
377 | */ |
378 | public static function getEditorUrl( MessageHandle $handle ): string { |
379 | if ( !$handle->isValid() ) { |
380 | return $handle->getTitle()->getLocalURL( [ 'action' => 'edit' ] ); |
381 | } |
382 | |
383 | $title = MediaWikiServices::getInstance() |
384 | ->getSpecialPageFactory()->getPage( 'Translate' )->getPageTitle(); |
385 | return $title->getFullURL( [ |
386 | 'showMessage' => $handle->getInternalKey(), |
387 | 'group' => $handle->getGroup()->getId(), |
388 | 'language' => $handle->getCode(), |
389 | ] ); |
390 | } |
391 | |
392 | /** |
393 | * Serialize the given value |
394 | * @param mixed $value |
395 | */ |
396 | public static function serialize( $value ): string { |
397 | return serialize( $value ); |
398 | } |
399 | |
400 | /** |
401 | * Deserialize the given string |
402 | * @return mixed |
403 | */ |
404 | public static function deserialize( string $str, array $opts = [ 'allowed_classes' => false ] ) { |
405 | return unserialize( $str, $opts ); |
406 | } |
407 | |
408 | public static function getVersion(): string { |
409 | // Avoid parsing JSON multiple time per request |
410 | static $version = null; |
411 | if ( $version === null ) { |
412 | $version = json_decode( file_get_contents( __DIR__ . '../../../extension.json' ) )->version; |
413 | } |
414 | return $version; |
415 | } |
416 | |
417 | /** |
418 | * Checks if the namespace that the title belongs to allows subpages |
419 | * |
420 | * @internal - For internal use only |
421 | * @param Title $title |
422 | * @return bool |
423 | */ |
424 | public static function allowsSubpages( Title $title ): bool { |
425 | $mwInstance = MediaWikiServices::getInstance(); |
426 | $namespaceInfo = $mwInstance->getNamespaceInfo(); |
427 | return $namespaceInfo->hasSubpages( $title->getNamespace() ); |
428 | } |
429 | |
430 | /** |
431 | * Checks whether a language code is supported for translation at the wiki level. |
432 | * Note that it is possible that message groups define other language codes which |
433 | * are not supported by the wiki, in which case this function would return false |
434 | * for those. |
435 | */ |
436 | public static function isSupportedLanguageCode( string $code ): bool { |
437 | $all = self::getLanguageNames( LanguageNameUtils::AUTONYMS ); |
438 | return isset( $all[ $code ] ); |
439 | } |
440 | |
441 | public static function getTextFromTextContent( ?Content $content ): string { |
442 | if ( !$content ) { |
443 | throw new UnexpectedValueException( 'Expected $content to be TextContent, got null instead.' ); |
444 | } |
445 | |
446 | if ( $content instanceof TextContent ) { |
447 | return $content->getText(); |
448 | } |
449 | |
450 | throw new UnexpectedValueException( 'Expected $content to be TextContent, but got ' . get_class( $content ) ); |
451 | } |
452 | |
453 | /** |
454 | * Returns all translations of a given message. |
455 | * @param MessageHandle $handle Language code is ignored. |
456 | * @return array ( string => array ( string, string ) ) Tuples of page |
457 | * text and last author indexed by page name. |
458 | */ |
459 | public static function getTranslations( MessageHandle $handle ): array { |
460 | $namespace = $handle->getTitle()->getNamespace(); |
461 | $base = $handle->getKey(); |
462 | |
463 | $dbr = MediaWikiServices::getInstance() |
464 | ->getDBLoadBalancer() |
465 | ->getConnection( DB_REPLICA ); |
466 | |
467 | $titles = $dbr->newSelectQueryBuilder() |
468 | ->select( 'page_title' ) |
469 | ->from( 'page' ) |
470 | ->where( [ |
471 | 'page_namespace' => $namespace, |
472 | 'page_title ' . $dbr->buildLike( "$base/", $dbr->anyString() ), |
473 | ] ) |
474 | ->caller( __METHOD__ ) |
475 | ->orderBy( 'page_title' ) |
476 | ->fetchFieldValues(); |
477 | |
478 | if ( $titles === [] ) { |
479 | return []; |
480 | } |
481 | |
482 | return self::getContents( $titles, $namespace ); |
483 | } |
484 | |
485 | public static function isTranslationPage( MessageHandle $handle ): bool { |
486 | // FIXME: A lot of this code is similar to TranslatablePage::isTranslationPage. |
487 | // See if they can be merged |
488 | // The major difference is that this method does not run a database query to check if |
489 | // the page is marked. |
490 | $key = $handle->getKey(); |
491 | $languageCode = $handle->getCode(); |
492 | if ( $key === '' || $languageCode === '' ) { |
493 | return false; |
494 | } |
495 | |
496 | $baseTitle = Title::makeTitle( $handle->getTitle()->getNamespace(), $key ); |
497 | if ( !TranslatablePage::isSourcePage( $baseTitle ) ) { |
498 | return false; |
499 | } |
500 | |
501 | static $codes = null; |
502 | if ( $codes === null ) { |
503 | $codes = self::getLanguageNames( LanguageNameUtils::AUTONYMS ); |
504 | } |
505 | |
506 | return !$handle->isDoc() && isset( $codes[ $languageCode ] ); |
507 | } |
508 | } |
509 | |
510 | class_alias( Utilities::class, 'TranslateUtils' ); |