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