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