Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.22% covered (warning)
73.22%
134 / 183
46.67% covered (danger)
46.67%
7 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Babel
73.63% covered (warning)
73.63%
134 / 182
46.67% covered (danger)
46.67%
7 / 15
98.71
0.00% covered (danger)
0.00%
0 / 1
 getConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 Render
91.89% covered (success)
91.89%
34 / 37
0.00% covered (danger)
0.00%
0 / 1
9.04
 mGenerateContentTower
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 setExtensionData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 mGenerateContent
66.67% covered (warning)
66.67%
28 / 42
0.00% covered (danger)
0.00%
0 / 1
8.81
 mTemplateLinkBatch
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 mPageExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 mValidTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 mParseParameter
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
7.04
 getUserLanguageInfo
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getCachedUserLanguageInfo
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 getLanguages
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 getCachedUserLanguages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserLanguages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserLanguagesDB
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
13.15
1<?php
2/**
3 * Contains main code.
4 *
5 * @file
6 * @author Robert Leverington
7 * @author Robin Pepermans
8 * @author Niklas Laxström
9 * @author Brian Wolff
10 * @author Purodha Blissenbach
11 * @author Sam Reed
12 * @author Siebrand Mazeland
13 * @license GPL-2.0-or-later
14 */
15
16declare( strict_types = 1 );
17
18namespace MediaWiki\Babel;
19
20use MediaWiki\Babel\BabelBox\LanguageBabelBox;
21use MediaWiki\Babel\BabelBox\NotBabelBox;
22use MediaWiki\Babel\BabelBox\NullBabelBox;
23use MediaWiki\Config\Config;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Title\Title;
26use MediaWiki\User\UserIdentity;
27use MediaWiki\WikiMap\WikiMap;
28use Parser;
29use ParserOutput;
30
31/**
32 * Main class for the Babel extension.
33 */
34class Babel {
35    /**
36     * @var Title
37     */
38    protected static $title;
39
40    /**
41     * Get a Config instance to use
42     *
43     * @todo Use proper Dependency Injection.
44     * @return Config
45     */
46    private static function getConfig(): Config {
47        return BabelServices::wrap( MediaWikiServices::getInstance() )->getConfig();
48    }
49
50    /**
51     * Render the Babel tower.
52     *
53     * @param Parser $parser
54     * @param string ...$parameters
55     * @return string Babel tower.
56     */
57    public static function Render( Parser $parser, string ...$parameters ): string {
58        self::$title = $parser->getTitle();
59
60        self::mTemplateLinkBatch( $parameters );
61
62        $parser->getOutput()->addModuleStyles( [ 'ext.babel' ] );
63
64        $content = self::mGenerateContentTower( $parser, $parameters );
65
66        if ( preg_match( '/^plain\s*=\s*\S/', reset( $parameters ) ) ) {
67            return $content;
68        }
69
70        if ( self::getConfig()->get( 'BabelUseUserLanguage' ) ) {
71            $uiLang = $parser->getOptions()->getUserLangObj();
72        } else {
73            $uiLang = self::$title->getPageLanguage();
74        }
75
76        $top = wfMessage( 'babel', self::$title->getDBkey() )->inLanguage( $uiLang );
77
78        if ( $top->isDisabled() ) {
79            $top = '';
80        } else {
81            $top = $top->text();
82            $url = wfMessage( 'babel-url' )->inContentLanguage();
83            if ( !$url->isDisabled() ) {
84                $top = '[[' . $url->text() . '|' . $top . ']]';
85            }
86            $top = '! class="mw-babel-header" | ' . $top;
87        }
88        $footer = wfMessage( 'babel-footer', self::$title->getDBkey() )->inLanguage( $uiLang );
89
90        $url = wfMessage( 'babel-footer-url' )->inContentLanguage();
91        $showFooter = '';
92        if ( !$footer->isDisabled() && !$url->isDisabled() ) {
93            $showFooter = '! class="mw-babel-footer" | [[' .
94                $url->text() . '|' . $footer->text() . ']]';
95        }
96
97        $tower = <<<EOT
98{|class="mw-babel-wrapper notheme"
99$top
100|-
101$content
102|-
103$showFooter
104|}
105EOT;
106        if ( self::getConfig()->get( 'BabelAllowOverride' ) ) {
107            // Make sure the page shows up as transcluding MediaWiki:babel-category-override
108            $title = Title::makeTitle( NS_MEDIAWIKI, "Babel-category-override" );
109            $revision = $parser->fetchCurrentRevisionRecordOfTitle( $title );
110            if ( $revision === null ) {
111                $revid = null;
112            } else {
113                $revid = $revision->getId();
114            }
115            // Passing null here when the page doesn't exist matches what the core parser does
116            // even though the documentation of the method says otherwise.
117            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
118            $parser->getOutput()->addTemplate( $title, $title->getArticleID(), $revid );
119        }
120        return $tower;
121    }
122
123    /**
124     * @param Parser $parser
125     * @param string[] $parameters
126     *
127     * @return string Wikitext
128     */
129    private static function mGenerateContentTower( Parser $parser, array $parameters ): string {
130        $content = '';
131        // collects name=value parameters to be passed to wiki templates.
132        $templateParameters = [];
133        $first = true;
134
135        $generateCategories = !preg_match( '/^nocat\s*=\s*\S/', reset( $parameters ) );
136
137        foreach ( $parameters as $name ) {
138            if ( strpos( $name, '=' ) !== false ) {
139                $templateParameters[] = $name;
140                if ( !$first || !preg_match( '/^(plain|nocat)\s*=\s*\S/', $name ) ) {
141                    $parser->addTrackingCategory( "babel-template-params-category" );
142                }
143                continue;
144            }
145
146            $content .= self::mGenerateContent( $parser, $name, $templateParameters, $generateCategories );
147            $first = false;
148        }
149
150        return $content;
151    }
152
153    private static function setExtensionData(
154        ParserOutput $parserOutput,
155        string $code,
156        string $level
157    ): void {
158        $data = $parserOutput->getExtensionData( 'babel' ) ?: [];
159        $data[ BabelLanguageCodes::getCategoryCode( $code ) ] = $level;
160        $parserOutput->setExtensionData( 'babel', $data );
161    }
162
163    /**
164     * @param Parser $parser
165     * @param string $name
166     * @param string[] $templateParameters
167     * @param bool $generateCategories Whether to add categories
168     * @return string Wikitext
169     */
170    private static function mGenerateContent(
171        Parser $parser,
172        string $name,
173        array $templateParameters,
174        bool $generateCategories
175    ): string {
176        $components = self::mParseParameter( $name );
177
178        $template = wfMessage( 'babel-template', $name )->inContentLanguage()->text();
179        $parserOutput = $parser->getOutput();
180
181        if ( $name === '' ) {
182            $box = new NullBabelBox();
183        } elseif ( $components !== false ) {
184            // Valid parameter syntax (with lowercase language code), babel box
185            $box = new LanguageBabelBox(
186                self::getConfig(),
187                self::$title,
188                $components['code'],
189                $components['level'],
190            );
191            self::setExtensionData( $parserOutput, $components['code'], $components['level'] );
192        } elseif ( self::mPageExists( $template ) ) {
193            // Check for an existing template
194            $templateParameters[0] = $template;
195            $template = implode( '|', $templateParameters );
196            $box = new NotBabelBox(
197                self::$title->getPageLanguage()->getDir(),
198                $parser->replaceVariables( "{{{$template}}}" )
199            );
200        } elseif ( self::mValidTitle( $template ) ) {
201            // Non-existing page, so try again as a babel box,
202            // with converting the code to lowercase
203            $components2 = self::mParseParameter( $name, true );
204            if ( $components2 !== false ) {
205                $box = new LanguageBabelBox(
206                    self::getConfig(),
207                    self::$title,
208                    $components2['code'],
209                    $components2['level'],
210                );
211                self::setExtensionData( $parserOutput,
212                    $components2['code'], $components2['level'] );
213            } else {
214                // Non-existent page and invalid parameter syntax, red link.
215                $box = new NotBabelBox(
216                    self::$title->getPageLanguage()->getDir(),
217                    '[[' . $template . ']]'
218                );
219            }
220        } else {
221            // Invalid title, output raw.
222            $box = new NotBabelBox(
223                self::$title->getPageLanguage()->getDir(),
224                $template
225            );
226        }
227        if ( $generateCategories ) {
228            $box->addCategories( $parserOutput );
229        }
230
231        return $box->render();
232    }
233
234    /**
235     * Performs a link batch on a series of templates.
236     *
237     * @param string[] $parameters Templates to perform the link batch on.
238     */
239    protected static function mTemplateLinkBatch( array $parameters ): void {
240        $titles = [];
241        foreach ( $parameters as $name ) {
242            $title = Title::newFromText( wfMessage( 'babel-template', $name )->inContentLanguage()->text() );
243            if ( is_object( $title ) ) {
244                $titles[] = $title;
245            }
246        }
247
248        $batch = MediaWikiServices::getInstance()->getLinkBatchFactory()->newLinkBatch( $titles );
249        $batch->setCaller( __METHOD__ );
250        $batch->execute();
251    }
252
253    /**
254     * Identify whether or not a page exists.
255     *
256     * @param string $name Name of the page to check.
257     * @return bool Indication of whether the page exists.
258     */
259    protected static function mPageExists( string $name ): bool {
260        $titleObj = Title::newFromText( $name );
261
262        return ( is_object( $titleObj ) && $titleObj->exists() );
263    }
264
265    /**
266     * Identify whether the passed string would make a valid page name.
267     *
268     * @param string $name Name of page to check.
269     * @return bool Indication of whether the title is valid.
270     */
271    protected static function mValidTitle( string $name ): bool {
272        $titleObj = Title::newFromText( $name );
273
274        return is_object( $titleObj );
275    }
276
277    /**
278     * Parse a parameter, getting a language code and level.
279     *
280     * @param string $parameter Parameter.
281     * @param bool $strtolower Whether to convert the language code to lowercase
282     * @return array|bool [ 'code' => xx, 'level' => xx ] false on failure
283     */
284    protected static function mParseParameter( string $parameter, bool $strtolower = false ) {
285        $return = [];
286
287        $babelCode = $strtolower ? strtolower( $parameter ) : $parameter;
288        // Try treating the parameter as a language code (for default level).
289        $code = BabelLanguageCodes::getCode( $babelCode );
290        if ( $code !== false ) {
291            $return['code'] = $code;
292            $return['level'] = self::getConfig()->get( 'BabelDefaultLevel' );
293            return $return;
294        }
295        // Try splitting the parameter in to language and level, split on last hyphen.
296        $lastSplit = strrpos( $parameter, '-' );
297        if ( $lastSplit === false ) {
298            return false;
299        }
300        $code = substr( $parameter, 0, $lastSplit );
301        $level = substr( $parameter, $lastSplit + 1 );
302
303        $babelCode = $strtolower ? strtolower( $code ) : $code;
304        // Validate code.
305        $return['code'] = BabelLanguageCodes::getCode( $babelCode );
306        if ( $return['code'] === false ) {
307            return false;
308        }
309        // Validate level.
310        $level = strtoupper( $level );
311        $categoryNames = self::getConfig()->get( 'BabelCategoryNames' );
312        if ( !isset( $categoryNames[$level] ) ) {
313            return false;
314        }
315        $return['level'] = $level;
316
317        return $return;
318    }
319
320    /**
321     * Gets the language information a user has set up with Babel.
322     * This function gets the actual info directly from the database.
323     * For performance, it is recommended to use
324     * getCachedUserLanguageInfo instead.
325     *
326     * @param UserIdentity $user
327     * @return string[] [ language code => level ], sorted by language code
328     */
329    public static function getUserLanguageInfo( UserIdentity $user ): array {
330        $userLanguageInfo = self::getUserLanguagesDB( $user );
331
332        ksort( $userLanguageInfo );
333
334        return $userLanguageInfo;
335    }
336
337    /**
338     * Gets the language information a user has set up with Babel,
339     * from the cache. It's recommended to use this when this will
340     * be called frequently.
341     *
342     * @param UserIdentity $user
343     * @return string[] [ language code => level ], sorted by language code
344     *
345     * @since Version 1.10.0
346     */
347    public static function getCachedUserLanguageInfo( UserIdentity $user ): array {
348        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
349        $userId = $user->getId();
350        $key = $cache->makeKey( 'babel-local-languages', $userId );
351        $checkKeys = [ $key ];
352        $centralId = MediaWikiServices::getInstance()->getCentralIdLookupFactory()
353                ->getLookup()->centralIdFromLocalUser( $user );
354
355        if ( $centralId ) {
356            $checkKeys[] = $cache->makeGlobalKey( 'babel-central-languages', $centralId );
357        }
358
359        return $cache->getWithSetCallback(
360            $key,
361            $cache::TTL_MINUTE * 30,
362            function ( $oldValue, &$ttl, array &$setOpts ) use ( $userId, $user ) {
363                wfDebug( "Babel: cache miss for user $userId\n" );
364
365                return self::getUserLanguageInfo( $user );
366            },
367            [
368                'checkKeys' => $checkKeys,
369            ]
370        );
371    }
372
373    /**
374     * Gets only the languages codes list out of the user language info.
375     *
376     * @param string[] $languageInfo [ language code => level ], the return value of
377     *   getUserLanguageInfo.
378     * @param string|null $level Minimal level as given in $wgBabelCategoryNames
379     * @return string[] List of language codes. Sorted by level if $level is not null,
380     *  otherwise sorted by language code
381     *
382     * @since Version 1.10.0
383     */
384    private static function getLanguages( array $languageInfo, ?string $level ): array {
385        if ( !$languageInfo ) {
386            return [];
387        }
388
389        if ( $level !== null ) {
390            // filter down the set, note that this uses a text sort!
391            $languageInfo = array_filter(
392                $languageInfo,
393                static function ( $value ) use ( $level ) {
394                    return ( strcmp( $value, $level ) >= 0 );
395                }
396            );
397            // sort and retain keys
398            uasort(
399                $languageInfo,
400                static function ( $a, $b ) {
401                    return -strcmp( $a, $b );
402                }
403            );
404        }
405
406        return array_keys( $languageInfo );
407    }
408
409    /**
410     * Gets the cached list of languages a user has set up with Babel.
411     *
412     * @param UserIdentity $user
413     * @param string|null $level Minimal level as given in $wgBabelCategoryNames
414     * @return string[] List of language codes. Sorted by level if $level is not null,
415     *  otherwise sorted by language code
416     *
417     * @since Version 1.10.0
418     */
419    public static function getCachedUserLanguages(
420        UserIdentity $user,
421        ?string $level = null
422    ): array {
423        return self::getLanguages( self::getCachedUserLanguageInfo( $user ), $level );
424    }
425
426    /**
427     * Gets the list of languages a user has set up with Babel.
428     * For performance reasons, it is recommended to use getCachedUserLanguages.
429     *
430     * @param UserIdentity $user
431     * @param string|null $level Minimal level as given in $wgBabelCategoryNames
432     * @return string[] List of language codes. Sorted by level if $level is not null,
433     *  otherwise sorted by language code
434     *
435     * @since Version 1.9.0
436     */
437    public static function getUserLanguages( UserIdentity $user, ?string $level = null ): array {
438        return self::getLanguages( self::getUserLanguageInfo( $user ), $level );
439    }
440
441    private static function getUserLanguagesDB( UserIdentity $user ): array {
442        $centralDb = self::getConfig()->get( 'BabelCentralDb' );
443
444        $babelDB = new Database();
445        $result = $babelDB->getForUser( $user->getId() );
446        /** If local data or no central source, return */
447        if ( $result || !$centralDb ) {
448            return $result;
449        }
450
451        if ( $centralDb === WikiMap::getCurrentWikiId() ) {
452            // We are the central wiki, so no fallback we can do
453            return [];
454        }
455
456        $lookup = MediaWikiServices::getInstance()->getCentralIdLookupFactory()->getLookup();
457        if ( !$lookup->isAttached( $user )
458            || !$lookup->isAttached( $user, $centralDb )
459        ) {
460            return [];
461        }
462
463        return $babelDB->getForRemoteUser( $centralDb, $user->getName() );
464    }
465}
466
467class_alias( Babel::class, 'Babel' );