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