Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 219 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
ActiveLanguagesSpecialPage | |
0.00% |
0 / 219 |
|
0.00% |
0 / 12 |
1056 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDescription | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
execute | |
0.00% |
0 / 41 |
|
0.00% |
0 / 1 |
42 | |||
showLanguage | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
6 | |||
languageCloud | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
12 | |||
filterUsers | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
outputLanguageCloud | |
0.00% |
0 / 18 |
|
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 Config; |
7 | use HtmlArmor; |
8 | use InvalidArgumentException; |
9 | use Language; |
10 | use LanguageCode; |
11 | use MediaWiki\Cache\LinkBatchFactory; |
12 | use MediaWiki\Config\ServiceOptions; |
13 | use MediaWiki\Extension\Translate\Utilities\ConfigHelper; |
14 | use MediaWiki\Html\Html; |
15 | use MediaWiki\Languages\LanguageFactory; |
16 | use MediaWiki\Languages\LanguageNameUtils; |
17 | use MediaWiki\Logger\LoggerFactory; |
18 | use MediaWiki\Title\Title; |
19 | use ObjectCache; |
20 | use SpecialPage; |
21 | use 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 | */ |
31 | class 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 | } |