Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
LanguageStatsSpecialPage.php
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Statistics;
5
7use DeferredUpdates;
8use DerivativeContext;
9use Html;
10use HTMLForm;
11use IContextSource;
12use JobQueueGroup;
13use MediaWiki\Cache\LinkBatchFactory;
16use Mediawiki\Languages\LanguageNameUtils;
17use MessageGroup;
20use ObjectCache;
21use SpecialPage;
22use Wikimedia\Rdbms\ILoadBalancer;
24
39class LanguageStatsSpecialPage extends SpecialPage {
41 private $languageNameUtils;
43 private $table;
45 private $targetValueName = [ 'code', 'language' ];
47 private $totals;
52 private $nothing = false;
57 private $incomplete = false;
62 private $noComplete = true;
67 private $noEmpty = false;
69 private $target;
74 private $purge;
81 private $statsCounted = [];
83 private $states;
85 private $linkBatchFactory;
87 private $progressStatsTableFactory;
89 private $jobQueueGroup;
91 private $loadBalancer;
92
93 public function __construct(
94 LinkBatchFactory $linkBatchFactory,
95 ProgressStatsTableFactory $progressStatsTableFactory,
96 LanguageNameUtils $languageNameUtils,
97 JobQueueGroup $jobQueueGroup,
98 ILoadBalancer $loadBalancer
99 ) {
100 parent::__construct( 'LanguageStats' );
101 $this->totals = MessageGroupStats::getEmptyStats();
102 $this->linkBatchFactory = $linkBatchFactory;
103 $this->progressStatsTableFactory = $progressStatsTableFactory;
104 $this->languageNameUtils = $languageNameUtils;
105 $this->jobQueueGroup = $jobQueueGroup;
106 $this->loadBalancer = $loadBalancer;
107 }
108
109 public function isIncludable() {
110 return true;
111 }
112
113 protected function getGroupName() {
114 return 'translation';
115 }
116
117 public function execute( $par ) {
118 $this->target = $this->getLanguage()->getCode();
119 $request = $this->getRequest();
120
121 $this->purge = $request->getVal( 'action' ) === 'purge';
122 if ( $this->purge && !$request->wasPosted() ) {
123 self::showPurgeForm( $this->getContext() );
124 return;
125 }
126
127 $this->table = $this->progressStatsTableFactory->newFromContext( $this->getContext() );
128
129 $this->setHeaders();
130 $this->outputHeader();
131
132 $out = $this->getOutput();
133
134 $out->addModules( 'ext.translate.special.languagestats' );
135 $out->addModuleStyles( 'ext.translate.statstable' );
136
137 $params = $par ? explode( '/', $par ) : [];
138
139 if ( isset( $params[0] ) && trim( $params[0] ) ) {
140 $this->target = $params[0];
141 }
142
143 if ( isset( $params[1] ) ) {
144 $this->noComplete = (bool)$params[1];
145 }
146
147 if ( isset( $params[2] ) ) {
148 $this->noEmpty = (bool)$params[2];
149 }
150
151 // Whether the form has been submitted, only relevant if not including
152 $submitted = !$this->including() && $request->getVal( 'x' ) === 'D';
153
154 // Default booleans to false if the form was submitted
155 foreach ( $this->targetValueName as $key ) {
156 $this->target = $request->getVal( $key, $this->target );
157 }
158 $this->noComplete = $request->getBool(
159 'suppresscomplete',
160 $this->noComplete && !$submitted
161 );
162 $this->noEmpty = $request->getBool( 'suppressempty', $this->noEmpty && !$submitted );
163
164 if ( !$this->including() ) {
165 $out->addHelpLink( 'Help:Extension:Translate/Statistics_and_reporting' );
166 $this->addForm();
167 }
168
169 if ( $this->isValidValue( $this->target ) ) {
170 $this->outputIntroduction();
171
172 $stats = $this->loadStatistics( $this->target, MessageGroupStats::FLAG_CACHE_ONLY );
173 $output = $this->getTable( $stats );
174 if ( $this->incomplete ) {
175 $out->wrapWikiMsg(
176 "<div class='error'>$1</div>",
177 'translate-langstats-incomplete'
178 );
179 }
180
181 if ( $this->incomplete || $this->purge ) {
182 DeferredUpdates::addCallableUpdate( function () {
183 // Attempt to recache on the fly the missing stats, unless a
184 // purge was requested, because that is likely to time out.
185 // Even though this is executed inside a deferred update, it
186 // counts towards the maximum execution time limit. If that is
187 // reached, or any other failure happens, no updates at all
188 // will be written into the database, as it does only single
189 // update at the end. Hence we always add a job too, so that
190 // even the slower updates will get done at some point. In
191 // regular case (no purge), the job sees that the stats are
192 // already updated, so it is not much of an overhead.
193 $jobParams = $this->getCacheRebuildJobParameters( $this->target );
194 $jobParams[ 'purge' ] = $this->purge;
195 $this->jobQueueGroup->push( MessageGroupStatsRebuildJob::newJob( $jobParams ) );
196
197 // $this->purge is only true if request was posted
198 if ( !$this->purge ) {
199 $this->loadStatistics( $this->target );
200 }
201 } );
202 }
203 if ( $this->nothing ) {
204 $out->wrapWikiMsg( "<div class='error'>$1</div>", 'translate-mgs-nothing' );
205 }
206 $out->addHTML( $output );
207 } elseif ( $submitted ) {
208 $this->invalidTarget();
209 }
210 }
211
218 private function loadStatistics( string $target, int $flags = 0 ): array {
219 return MessageGroupStats::forLanguage( $target, $flags );
220 }
221
222 private function getCacheRebuildJobParameters( $target ): array {
223 return [ 'languagecode' => $target ];
224 }
225
227 private function isValidValue( string $value ): bool {
228 $langs = $this->languageNameUtils->getLanguageNames();
229
230 return isset( $langs[$value] );
231 }
232
234 private function invalidTarget(): void {
235 $this->getOutput()->wrapWikiMsg(
236 "<div class='error'>$1</div>",
237 'translate-page-no-such-language'
238 );
239 }
240
241 public static function showPurgeForm( IContextSource $context ): void {
242 $formDescriptor = [
243 'intro' => [
244 'type' => 'info',
245 'vertical-label' => true,
246 'raw' => true,
247 'default' => $context->msg( 'confirm-purge-top' )->parse()
248 ],
249 ];
250
251 $derivativeContext = new DerivativeContext( $context );
252 $requestValues = $derivativeContext->getRequest()->getQueryValues();
253
254 HTMLForm::factory( 'ooui', $formDescriptor, $derivativeContext )
255 ->setWrapperLegendMsg( 'confirm-purge-title' )
256 ->setSubmitTextMsg( 'confirm_purge_button' )
257 ->addHiddenFields( $requestValues )
258 ->show();
259 }
260
262 private function addForm(): void {
263 $formDescriptor = [
264 'language' => [
265 'type' => 'text',
266 'name' => 'language',
267 'id' => 'language',
268 'label' => $this->msg( 'translate-language-code-field-name' )->text(),
269 'size' => 10,
270 'default' => $this->target,
271 ],
272 'suppresscomplete' => [
273 'type' => 'check',
274 'label' => $this->msg( 'translate-suppress-complete' )->text(),
275 'name' => 'suppresscomplete',
276 'id' => 'suppresscomplete',
277 'default' => $this->noComplete,
278 ],
279 'suppressempty' => [
280 'type' => 'check',
281 'label' => $this->msg( 'translate-ls-noempty' )->text(),
282 'name' => 'suppressempty',
283 'id' => 'suppressempty',
284 'default' => $this->noEmpty,
285 ],
286 ];
287
288 $context = new DerivativeContext( $this->getContext() );
289 $context->setTitle( $this->getPageTitle() ); // Remove subpage
290
291 $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $context );
292
293 /* Since these pages are in the tabgroup with Special:Translate,
294 * it makes sense to retain the selected group/language parameter
295 * on post requests even when not relevant to the current page. */
296 $val = $this->getRequest()->getVal( 'group' );
297 if ( $val !== null ) {
298 $htmlForm->addHiddenField( 'group', $val );
299 }
300
301 $htmlForm
302 ->addHiddenField( 'x', 'D' ) // To detect submission
303 ->setMethod( 'get' )
304 ->setSubmitTextMsg( 'translate-ls-submit' )
305 ->setWrapperLegendMsg( 'translate-mgs-fieldset' )
306 ->prepareForm()
307 ->displayForm( false );
308 }
309
311 private function outputIntroduction(): void {
312 $languageName = Utilities::getLanguageName(
313 $this->target,
314 $this->getLanguage()->getCode()
315 );
316
317 $rcInLangLink = $this->getLinkRenderer()->makeKnownLink(
318 SpecialPage::getTitleFor( 'Translate', '!recent' ),
319 $this->msg( 'languagestats-recenttranslations' )->text(),
320 [],
321 [
322 'action' => 'proofread',
323 'language' => $this->target
324 ]
325 );
326
327 $out = $this->msg( 'languagestats-stats-for', $languageName )->rawParams( $rcInLangLink )
328 ->parseAsBlock();
329 $this->getOutput()->addHTML( $out );
330 }
331
333 private function addWorkflowStatesColumn(): void {
334 global $wgTranslateWorkflowStates;
335
336 if ( $wgTranslateWorkflowStates ) {
337 $this->states = $this->getWorkflowStates();
338
339 // An array where keys are state names and values are numbers
340 $this->table->addExtraColumn( $this->msg( 'translate-stats-workflow' ) );
341 }
342 }
343
344 private function getWorkflowStateCell( string $messageGroupId ): string {
345 // This will be set by addWorkflowStatesColumn if needed
346 if ( !isset( $this->states ) ) {
347 return '';
348 }
349
350 return $this->table->makeWorkflowStateCell(
351 $this->states[$messageGroupId] ?? null,
352 MessageGroups::getGroup( $messageGroupId ),
353 $this->target
354 );
355 }
356
357 private function getTable( array $stats ): string {
358 $table = $this->table;
359
360 $this->addWorkflowStatesColumn();
361 $out = '';
362
363 // This avoids a database query per translatable page, which would be caused by
364 // $group->getSourceLanguage() in $this->getWorkflowStateCell without preloading
365 $lb = $this->linkBatchFactory->newLinkBatch();
366 foreach ( MessageGroups::getAllGroups() as $group ) {
367 if ( $group instanceof WikiPageMessageGroup ) {
368 $lb->addObj( $group->getTitle() );
369 }
370 }
371 $lb->setCaller( __METHOD__ )->execute();
372
373 $structure = MessageGroups::getGroupStructure();
374 foreach ( $structure as $item ) {
375 $out .= $this->makeGroupGroup( $item, $stats );
376 }
377
378 if ( $out ) {
379 $table->setMainColumnHeader( $this->msg( 'translate-ls-column-group' ) );
380 $out = $table->createHeader() . "\n" . $out;
381 $out .= Html::closeElement( 'tbody' );
382
383 $out .= Html::openElement( 'tfoot' );
384 $out .= $table->makeTotalRow(
385 $this->msg( 'translate-languagestats-overall' ),
386 $this->totals
387 );
388 $out .= Html::closeElement( 'tfoot' );
389
390 $out .= Html::closeElement( 'table' );
391
392 return $out;
393 } else {
394 $this->nothing = true;
395
396 return '';
397 }
398 }
399
409 private function makeGroupGroup( $item, array $cache, MessageGroup $parent = null ): string {
410 if ( !is_array( $item ) ) {
411 return $this->makeGroupRow( $item, $cache, $parent );
412 }
413
414 // The first group in the array is the parent AggregateMessageGroup
415 $out = '';
416 $top = array_shift( $item );
417 $out .= $this->makeGroupRow( $top, $cache, $parent );
418
419 // Rest are children
420 foreach ( $item as $subgroup ) {
421 $out .= $this->makeGroupGroup( $subgroup, $cache, $top );
422 }
423
424 return $out;
425 }
426
431 private function makeGroupRow(
432 MessageGroup $group,
433 array $cache,
434 MessageGroup $parent = null
435 ): string {
436 $groupId = $group->getId();
437
438 if ( $this->table->isExcluded( $group, $this->target ) ) {
439 return '';
440 }
441
442 $stats = $cache[$groupId];
443 $total = $stats[MessageGroupStats::TOTAL];
444 $translated = $stats[MessageGroupStats::TRANSLATED];
445 $fuzzy = $stats[MessageGroupStats::FUZZY];
446
447 // Quick checks to see whether filters apply
448 if ( $this->noComplete && $fuzzy === 0 && $translated === $total ) {
449 return '';
450 }
451 if ( $this->noEmpty && $translated === 0 && $fuzzy === 0 ) {
452 return '';
453 }
454
455 if ( $total === null ) {
456 $this->incomplete = true;
457 }
458
459 // Calculation of summary row values
460 if ( !$group instanceof AggregateMessageGroup &&
461 !isset( $this->statsCounted[$groupId] )
462 ) {
463 $this->totals = MessageGroupStats::multiAdd( $this->totals, $stats );
464 $this->statsCounted[$groupId] = true;
465 }
466
467 // Place any state checks like $this->incomplete above this
468 $params = $stats;
469 $params[] = $this->states[$groupId] ?? '';
470 $params[] = md5( $groupId );
471 $params[] = $this->getLanguage()->getCode();
472 $params[] = md5( $this->target );
473 $params[] = $parent ? $parent->getId() : '!';
474
475 $cache = ObjectCache::getInstance( CACHE_ANYTHING );
476
477 return $cache->getWithSetCallback(
478 $cache->makeKey( __METHOD__ . '-v3', implode( '-', $params ) ),
479 $cache::TTL_DAY,
480 function () use ( $translated, $total, $groupId, $group, $parent, $stats ) {
481 // Any data variable read below should be part of the cache key above
482 $extra = [];
483 if ( $translated === $total ) {
484 $extra = [ 'action' => 'proofread' ];
485 }
486
487 $rowParams = [];
488 $rowParams['data-groupid'] = $groupId;
489 $rowParams['class'] = get_class( $group );
490 if ( $parent ) {
491 $rowParams['data-parentgroup'] = $parent->getId();
492 }
493
494 return "\t" .
495 Html::openElement( 'tr', $rowParams ) .
496 "\n\t\t" .
497 Html::rawElement(
498 'td',
499 [],
500 $this->table->makeGroupLink( $group, $this->target, $extra )
501 ) . $this->table->makeNumberColumns( $stats ) .
502 $this->getWorkflowStateCell( $groupId ) .
503 "\n\t" .
504 Html::closeElement( 'tr' ) .
505 "\n";
506 }
507 );
508 }
509
510 private function getWorkflowStates(): array {
511 $db = $this->loadBalancer->getConnection( DB_REPLICA );
512 $res = $db->select(
513 'translate_groupreviews',
514 [ 'tgr_state', 'tgr_group' ],
515 [ 'tgr_lang' => $this->target ],
516 __METHOD__
517 );
518
519 $states = [];
520 foreach ( $res as $row ) {
521 $states[$row->tgr_group] = $row->tgr_state;
522 }
523
524 return $states;
525 }
526}
Groups multiple message groups together as one group.
Factory class for accessing message groups individually by id or all of them as a list.
Implements includable special page Special:LanguageStats which provides translation statistics for al...
Essentially random collection of helper functions, similar to GlobalFunctions.php.
Definition Utilities.php:30
Job for rebuilding message group stats.
This class abstract MessageGroup statistics calculation and storing.
Wraps the translatable page sections into a message group.
Interface for message groups.
getId()
Returns the unique identifier for this group.