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