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