Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 221 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
ActiveLanguagesSpecialPage | |
0.00% |
0 / 221 |
|
0.00% |
0 / 12 |
992 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDescription | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 44 |
|
0.00% |
0 / 1 |
42 | |||
showLanguage | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
6 | |||
languageCloud | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
12 | |||
filterUsers | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
outputLanguageCloud | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
12 | |||
makeUserList | |
0.00% |
0 / 51 |
|
0.00% |
0 / 1 |
30 | |||
formatStyle | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
preQueryUsers | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getColorLegend | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\Statistics; |
5 | |
6 | use HtmlArmor; |
7 | use InvalidArgumentException; |
8 | use MediaWiki\Cache\LinkBatchFactory; |
9 | use MediaWiki\Config\Config; |
10 | use MediaWiki\Config\ServiceOptions; |
11 | use MediaWiki\Extension\Translate\LogNames; |
12 | use MediaWiki\Extension\Translate\Utilities\ConfigHelper; |
13 | use MediaWiki\Html\Html; |
14 | use MediaWiki\Language\Language; |
15 | use MediaWiki\Language\LanguageCode; |
16 | use MediaWiki\Languages\LanguageFactory; |
17 | use MediaWiki\Languages\LanguageNameUtils; |
18 | use MediaWiki\Logger\LoggerFactory; |
19 | use MediaWiki\SpecialPage\SpecialPage; |
20 | use MediaWiki\Title\Title; |
21 | use ObjectCacheFactory; |
22 | use Wikimedia\ObjectCache\BagOStuff; |
23 | use 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 | */ |
33 | class 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 | } |