Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 221
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ActiveLanguagesSpecialPage
0.00% covered (danger)
0.00%
0 / 221
0.00% covered (danger)
0.00%
0 / 12
992
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
42
 showLanguage
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
6
 languageCloud
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 filterUsers
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 outputLanguageCloud
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 makeUserList
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
30
 formatStyle
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 preQueryUsers
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getColorLegend
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Statistics;
5
6use HtmlArmor;
7use InvalidArgumentException;
8use MediaWiki\Cache\LinkBatchFactory;
9use MediaWiki\Config\Config;
10use MediaWiki\Config\ServiceOptions;
11use MediaWiki\Extension\Translate\LogNames;
12use MediaWiki\Extension\Translate\Utilities\ConfigHelper;
13use MediaWiki\Html\Html;
14use MediaWiki\Language\Language;
15use MediaWiki\Language\LanguageCode;
16use MediaWiki\Languages\LanguageFactory;
17use MediaWiki\Languages\LanguageNameUtils;
18use MediaWiki\Logger\LoggerFactory;
19use MediaWiki\SpecialPage\SpecialPage;
20use MediaWiki\Title\Title;
21use ObjectCacheFactory;
22use Wikimedia\ObjectCache\BagOStuff;
23use Wikimedia\Rdbms\ILoadBalancer;
24
25/**
26 * This special page shows active languages and active translators per language.
27 *
28 * @author Niklas Laxström
29 * @author Siebrand Mazeland
30 * @license GPL-2.0-or-later
31 * @ingroup SpecialPage TranslateSpecialPage Stats
32 */
33class ActiveLanguagesSpecialPage extends SpecialPage {
34    private ServiceOptions $options;
35    private TranslatorActivity $translatorActivity;
36    private LanguageNameUtils $langNameUtils;
37    private ILoadBalancer $loadBalancer;
38    private ConfigHelper $configHelper;
39    private Language $contentLanguage;
40    private ProgressStatsTableFactory $progressStatsTableFactory;
41    private StatsTable $progressStatsTable;
42    private LinkBatchFactory $linkBatchFactory;
43    private LanguageFactory $languageFactory;
44    private BagOStuff $cache;
45    /** Cutoff time for inactivity in days */
46    private int $period = 180;
47
48    public const CONSTRUCTOR_OPTIONS = [
49        'TranslateMessageNamespaces',
50    ];
51
52    public function __construct(
53        Config $config,
54        TranslatorActivity $translatorActivity,
55        LanguageNameUtils $langNameUtils,
56        ILoadBalancer $loadBalancer,
57        ConfigHelper $configHelper,
58        Language $contentLanguage,
59        ProgressStatsTableFactory $progressStatsTableFactory,
60        LinkBatchFactory $linkBatchFactory,
61        LanguageFactory $languageFactory,
62        ObjectCacheFactory $objectCacheFactory
63    ) {
64        parent::__construct( 'SupportedLanguages' );
65        $this->options = new ServiceOptions( self::CONSTRUCTOR_OPTIONS, $config );
66        $this->translatorActivity = $translatorActivity;
67        $this->langNameUtils = $langNameUtils;
68        $this->loadBalancer = $loadBalancer;
69        $this->configHelper = $configHelper;
70        $this->contentLanguage = $contentLanguage;
71        $this->progressStatsTableFactory = $progressStatsTableFactory;
72        $this->linkBatchFactory = $linkBatchFactory;
73        $this->languageFactory = $languageFactory;
74        $this->cache = $objectCacheFactory->getInstance( CACHE_ANYTHING );
75    }
76
77    protected function getGroupName() {
78        return 'translation';
79    }
80
81    public function getDescription() {
82        return $this->msg( 'supportedlanguages' );
83    }
84
85    public function execute( $par ): void {
86        $out = $this->getOutput();
87        $lang = $this->getLanguage();
88        $this->progressStatsTable = $this->progressStatsTableFactory->newFromContext( $this->getContext() );
89
90        $this->setHeaders();
91        $out->addModuleStyles( [
92            'ext.translate.specialpages.styles',
93            'mediawiki.codex.messagebox.styles',
94        ] );
95
96        $out->addHelpLink(
97            'Help:Extension:Translate/Statistics_and_reporting#List_of_languages_and_translators'
98        );
99
100        $this->outputHeader( 'supportedlanguages-summary' );
101        $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
102        $dbType = $dbr->getType();
103        if ( $dbType === 'sqlite' || $dbType === 'postgres' ) {
104            $out->addHTML(
105                Html::errorBox(
106                    // Messages used: supportedlanguages-sqlite-error, supportedlanguages-postgres-error
107                    $out->msg( 'supportedlanguages-' . $dbType . '-error' )->parse()
108                )
109            );
110            return;
111        }
112
113        $out->addWikiMsg( 'supportedlanguages-localsummary' );
114
115        $names = $this->langNameUtils->getLanguageNames( LanguageNameUtils::AUTONYMS, LanguageNameUtils::ALL );
116        $languages = $this->languageCloud();
117        // There might be all sorts of subpages which are not languages
118        $languages = array_intersect_key( $languages, $names );
119
120        $this->outputLanguageCloud( $languages, $names );
121        $out->addWikiMsg( 'supportedlanguages-count', $lang->formatNum( count( $languages ) ) );
122
123        if ( !$par ) {
124            return;
125        }
126
127        // Convert formatted language tag like zh-Hant to internal format like zh-hant
128        $language = strtolower( $par );
129        try {
130            $data = $this->translatorActivity->inLanguage( $language );
131        } catch ( StatisticsUnavailable $e ) {
132            // generic-pool-error is from MW core
133            $out->addHTML( Html::errorBox( $this->msg( 'generic-pool-error' )->parse() ) );
134            return;
135        } catch ( InvalidArgumentException $e ) {
136            $errorMessageHtml = $this->msg( 'translate-activelanguages-invalid-code' )
137                ->params( LanguageCode::bcp47( $language ) )
138                ->parse();
139            $out->addHTML( Html::errorBox( $errorMessageHtml ) );
140            return;
141        }
142
143        $users = $data['users'];
144        $users = $this->filterUsers( $users, $language );
145        $this->preQueryUsers( $users );
146        $this->showLanguage( $language, $users, (int)$data['asOfTime'] );
147    }
148
149    private function showLanguage( string $code, array $users, int $cachedAt ): void {
150        $out = $this->getOutput();
151        $lang = $this->getLanguage();
152
153        // Information to be used inside the foreach loop.
154        $linkInfo = [];
155        $linkInfo['rc']['title'] = SpecialPage::getTitleFor( 'Recentchanges' );
156        $linkInfo['rc']['msg'] = $this->msg( 'supportedlanguages-recenttranslations' )->text();
157        $linkInfo['stats']['title'] = SpecialPage::getTitleFor( 'LanguageStats' );
158        $linkInfo['stats']['msg'] = $this->msg( 'languagestats' )->text();
159
160        $local = $this->langNameUtils->getLanguageName( $code, $lang->getCode() );
161        $native = $this->langNameUtils->getLanguageName( $code );
162        $statLanguage = $this->languageFactory->getLanguage( $code );
163        $bcp47Code = $statLanguage->getHtmlCode();
164
165        $span = Html::rawElement( 'span', [ 'lang' => $bcp47Code, 'dir' => $statLanguage->getDir() ], $native );
166
167        if ( $local !== $native ) {
168
169            $headerText = $this->msg( 'supportedlanguages-portallink' )
170                ->params( $bcp47Code, $local, $span )->parse();
171        } else {
172            // No CLDR, so a less localised header and link title.
173            $headerText = $this->msg( 'supportedlanguages-portallink-nocldr' )
174                ->params( $bcp47Code, $span )->parse();
175        }
176
177        $out->addHTML( Html::rawElement( 'h2', [ 'id' => $code ], $headerText ) );
178
179        // Add useful links for language stats and recent changes for the language.
180        $links = [];
181        $links[] = $this->getLinkRenderer()->makeKnownLink(
182            $linkInfo['stats']['title'],
183            $linkInfo['stats']['msg'],
184            [],
185            [
186                'code' => $code,
187                'suppresscomplete' => '1'
188            ]
189        );
190        $links[] = $this->getLinkRenderer()->makeKnownLink(
191            $linkInfo['rc']['title'],
192            $linkInfo['rc']['msg'],
193            [],
194            [
195                'translations' => 'only',
196                'trailer' => '/' . $code
197            ]
198        );
199        $linkList = $lang->listToText( $links );
200
201        $out->addHTML( '<p>' . $linkList . "</p>\n" );
202        $this->makeUserList( $users );
203
204        $ageString = $this->getLanguage()->formatTimePeriod(
205            time() - $cachedAt,
206            [ 'noabbrevs' => true, 'avoid' => 'avoidseconds' ]
207        );
208        $out->addWikiMsg( 'supportedlanguages-colorlegend', $this->getColorLegend() );
209        $out->addWikiMsg( 'translate-supportedlanguages-cached', $ageString );
210    }
211
212    private function languageCloud(): array {
213        $cacheKey = $this->cache->makeKey( 'translate-supportedlanguages-language-cloud', 'v2' );
214
215        $data = $this->cache->get( $cacheKey );
216        if ( is_array( $data ) ) {
217            return $data;
218        }
219
220        $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
221        $timestamp = $dbr->timestamp( (int)wfTimestamp() - 60 * 60 * 24 * $this->period );
222
223        $res = $dbr->newSelectQueryBuilder()
224            ->select( [ 'lang' => 'substring_index(rc_title, \'/\', -1)', 'count' => 'COUNT(*)' ] )
225            ->from( 'recentchanges' )
226            ->where( [
227                'rc_timestamp > ' . $dbr->addQuotes( $timestamp ),
228                'rc_namespace' => $this->options->get( 'TranslateMessageNamespaces' ),
229                'rc_title' . $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() ),
230            ] )
231            ->groupBy( 'lang' )
232            ->having( 'count > 20' )
233            ->orderBy( 'NULL' )
234            ->caller( __METHOD__ )
235            ->fetchResultSet();
236
237        $data = [];
238        foreach ( $res as $row ) {
239            $data[$row->lang] = (int)$row->count;
240        }
241
242        $this->cache->set( $cacheKey, $data, 3600 );
243
244        return $data;
245    }
246
247    protected function filterUsers( array $users, string $code ): array {
248        foreach ( $users as $index => $user ) {
249            $username = $user[TranslatorActivityQuery::USER_NAME];
250            // We do not know the group
251            if ( $this->configHelper->isAuthorExcluded( '#', $code, $username ) ) {
252                unset( $users[$index] );
253            }
254        }
255
256        return $users;
257    }
258
259    protected function outputLanguageCloud( array $languages, array $names ) {
260        $out = $this->getOutput();
261
262        $out->addHTML( '<div class="tagcloud autonym">' );
263
264        $translateDocumentationLanguageCode = $this->getConfig()->get( 'TranslateDocumentationLanguageCode' );
265        foreach ( $languages as $k => $v ) {
266            $name = $names[$k];
267            $langAttribute = $k;
268            $size = round( log( $v ) * 20 ) + 10;
269
270            if ( $langAttribute === $translateDocumentationLanguageCode ) {
271                $langAttribute = $this->contentLanguage->getHtmlCode();
272            } else {
273                $langAttribute = LanguageCode::bcp47( $langAttribute );
274            }
275
276            $params = [
277                'href' => $this->getPageTitle( $k )->getLocalURL(),
278                'class' => 'tag',
279                'style' => "font-size:$size%",
280                'lang' => $langAttribute,
281            ];
282
283            $tag = Html::element( 'a', $params, $name );
284            $out->addHTML( $tag . "\n" );
285        }
286        $out->addHTML( '</div>' );
287    }
288
289    private function makeUserList( array $userStats ): void {
290        $day = 60 * 60 * 24;
291
292        // Scale of the activity colors, anything
293        // longer than this is just inactive
294        $period = $this->period;
295
296        $links = [];
297        // List users in descending order by number of translations in this language
298        usort( $userStats, static function ( $a, $b ) {
299            return -(
300                $a[TranslatorActivityQuery::USER_TRANSLATIONS]
301                <=>
302                $b[TranslatorActivityQuery::USER_TRANSLATIONS]
303            );
304        } );
305
306        foreach ( $userStats as $stats ) {
307            $username = $stats[TranslatorActivityQuery::USER_NAME];
308            $title = Title::makeTitleSafe( NS_USER, $username );
309            if ( !$title ) {
310                LoggerFactory::getInstance( LogNames::MAIN )->warning(
311                    "T248125: Got Title-invalid username '{username}'",
312                    [ 'username' => $username ]
313                );
314                continue;
315            }
316
317            $count = $stats[TranslatorActivityQuery::USER_TRANSLATIONS];
318            $lastTranslationTimestamp = $stats[TranslatorActivityQuery::USER_LAST_ACTIVITY];
319
320            $enc = htmlspecialchars( $username );
321
322            $attribs = [];
323            $styles = [];
324            $styles['font-size'] = round( log( $count, 10 ) * 30 ) + 70 . '%';
325
326            $last = (int)wfTimestamp() - (int)wfTimestamp( TS_UNIX, $lastTranslationTimestamp );
327            $last = round( $last / $day );
328            $attribs['title'] =
329                $this->msg( 'supportedlanguages-activity', $username )
330                    ->numParams( $count, $last )
331                    ->text();
332            $last = max( 1, min( $period, $last ) );
333            $styles['border-bottom'] =
334                '3px solid #' . $this->progressStatsTable->getBackgroundColor( ( $period - $last ) / $period );
335
336            $stylestr = $this->formatStyle( $styles );
337            if ( $stylestr ) {
338                $attribs['style'] = $stylestr;
339            }
340
341            $links[] =
342                $this->getLinkRenderer()->makeLink( $title, new HtmlArmor( $enc ), $attribs );
343        }
344
345        // for GENDER support
346        $usernameForGender = '';
347        if ( count( $userStats ) === 1 ) {
348            $usernameForGender = $userStats[0][TranslatorActivityQuery::USER_NAME];
349        }
350
351        $linkList = $this->getLanguage()->listToText( $links );
352        $html = "<p class='mw-translate-spsl-translators'>";
353        $html .= $this->msg( 'supportedlanguages-translators' )
354            ->rawParams( $linkList )
355            ->numParams( count( $links ) )
356            ->params( $usernameForGender )
357            ->escaped();
358        $html .= "</p>\n";
359        $this->getOutput()->addHTML( $html );
360    }
361
362    protected function formatStyle( $styles ) {
363        $stylestr = '';
364        foreach ( $styles as $key => $value ) {
365            $stylestr .= "$key:$value;";
366        }
367
368        return $stylestr;
369    }
370
371    protected function preQueryUsers( array $users ): void {
372        $lb = $this->linkBatchFactory->newLinkBatch();
373        foreach ( $users as $data ) {
374            $username = $data[TranslatorActivityQuery::USER_NAME];
375            $user = Title::capitalize( $username, NS_USER );
376            $lb->add( NS_USER, $user );
377            $lb->add( NS_USER_TALK, $user );
378        }
379        $lb->execute();
380    }
381
382    protected function getColorLegend() {
383        $legend = '';
384        $period = $this->period;
385
386        for ( $i = 0; $i <= $period; $i += 30 ) {
387            $iFormatted = htmlspecialchars( $this->getLanguage()->formatNum( $i ) );
388            $legend .= '<span style="background-color:#' .
389                $this->progressStatsTable->getBackgroundColor( ( $period - $i ) / $period ) .
390                "\"> $iFormatted</span>";
391        }
392
393        return $legend;
394    }
395}