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