Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.34% covered (danger)
15.34%
25 / 163
4.17% covered (danger)
4.17%
1 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Utilities
15.43% covered (danger)
15.43%
25 / 162
4.17% covered (danger)
4.17%
1 / 24
1952.67
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%
25 / 25
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
 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
 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 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 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 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 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 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 isTranslationPage
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Utilities;
5
6use MediaWiki\Config\ConfigException;
7use MediaWiki\Content\Content;
8use MediaWiki\Content\TextContent;
9use MediaWiki\Context\RequestContext;
10use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
11use MediaWiki\Extension\Translate\PageTranslation\Hooks as PageTranslationHooks;
12use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage;
13use MediaWiki\Extension\Translate\Services;
14use MediaWiki\Language\LanguageCode;
15use MediaWiki\Languages\LanguageNameUtils;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Revision\RevisionRecord;
18use MediaWiki\Revision\SlotRecord;
19use MediaWiki\Title\Title;
20use MediaWiki\Xml\Xml;
21use MediaWiki\Xml\XmlSelect;
22use MessageGroup;
23use UnexpectedValueException;
24use 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 */
31class 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', '&#160;', $msg );
244        $msg = preg_replace( '/ $/m', '&#160;', $msg );
245        $msg = preg_replace( '/  /', '&#160; ', $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
475class_alias( Utilities::class, 'TranslateUtils' );