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