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