Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.34% covered (danger)
15.34%
27 / 176
3.70% covered (danger)
3.70%
1 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Utilities
15.43% covered (danger)
15.43%
27 / 175
3.70% covered (danger)
3.70%
1 / 27
2237.58
0.00% covered (danger)
0.00%
0 / 1
 title
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 figureMessage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getMessageContent
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getContents
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
4
 getContentForTitle
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 getLanguageName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 languageSelector
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getLanguageSelector
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getLanguageNames
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 messageKeyToGroups
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 normaliseKey
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 fieldset
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 convertWhiteSpaceToHTML
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 cacheFile
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getPlaceholder
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getIcon
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 getSafeReadDB
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 shouldReadFromPrimary
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getEditorUrl
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 serialize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 deserialize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getVersion
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 allowsSubpages
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 isSupportedLanguageCode
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTextFromTextContent
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getTranslations
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 isTranslationPage
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Utilities;
5
6use ConfigException;
7use Content;
8use LanguageCode;
9use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
10use MediaWiki\Extension\Translate\PageTranslation\Hooks as PageTranslationHooks;
11use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage;
12use MediaWiki\Extension\Translate\Services;
13use MediaWiki\Languages\LanguageNameUtils;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Revision\RevisionRecord;
16use MediaWiki\Revision\SlotRecord;
17use MediaWiki\Title\Title;
18use MediaWiki\Title\TitleValue;
19use MessageGroup;
20use RequestContext;
21use TextContent;
22use UnexpectedValueException;
23use Wikimedia\Rdbms\IDatabase;
24use Xml;
25use 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 */
32class 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', '&#160;', $msg );
281        $msg = preg_replace( '/ $/m', '&#160;', $msg );
282        $msg = preg_replace( '/  /', '&#160; ', $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
510class_alias( Utilities::class, 'TranslateUtils' );