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