Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
LanguageStatsSpecialPage.php
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Statistics;
5
7use JobQueueGroup;
8use MediaWiki\Cache\LinkBatchFactory;
9use MediaWiki\Context\DerivativeContext;
10use MediaWiki\Context\IContextSource;
11use MediaWiki\Deferred\DeferredUpdates;
15use MediaWiki\Html\Html;
16use MediaWiki\HTMLForm\HTMLForm;
17use MediaWiki\Languages\LanguageNameUtils;
18use MediaWiki\SpecialPage\SpecialPage;
19use MessageGroup;
20use ObjectCacheFactory;
21use Wikimedia\ObjectCache\BagOStuff;
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 MessageGroupReviewStore $groupReviewStore;
67 private BagOStuff $cache;
68
69 public function __construct(
70 LinkBatchFactory $linkBatchFactory,
71 ProgressStatsTableFactory $progressStatsTableFactory,
72 LanguageNameUtils $languageNameUtils,
73 JobQueueGroup $jobQueueGroup,
74 MessageGroupReviewStore $groupReviewStore,
75 ObjectCacheFactory $objectCacheFactory
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->groupReviewStore = $groupReviewStore;
84 $this->cache = $objectCacheFactory->getInstance( CACHE_ANYTHING );
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 $table = $this->table;
324 $out = '';
325
326 // This avoids a database query per translatable page, which would be caused by
327 // $group->getSourceLanguage() in $this->getWorkflowStateCell without preloading
328 $lb = $this->linkBatchFactory->newLinkBatch();
329 foreach ( MessageGroups::getAllGroups() as $group ) {
330 if ( $group instanceof WikiPageMessageGroup ) {
331 $lb->addObj( $group->getTitle() );
332 }
333 }
334 $lb->setCaller( __METHOD__ )->execute();
335
336 $structure = MessageGroups::getGroupStructure();
337
338 if ( $this->getConfig()->get( 'TranslateWorkflowStates' ) ) {
339 $this->states = $this->groupReviewStore->getWorkflowStatesForLanguage(
340 $this->target,
341 array_keys( $structure )
342 );
343 // An array where keys are state names and values are numbers
344 $this->table->addExtraColumn( $this->msg( 'translate-stats-workflow' ) );
345 }
346
347 foreach ( $structure as $item ) {
348 $out .= $this->makeGroupGroup( $item, $stats );
349 }
350
351 if ( $out ) {
352 $table->setMainColumnHeader( $this->msg( 'translate-ls-column-group' ) );
353 $out = $table->createHeader() . "\n" . $out;
354 $out .= Html::closeElement( 'tbody' );
355
356 $out .= Html::openElement( 'tfoot' );
357 $out .= $table->makeTotalRow(
358 $this->msg( 'translate-languagestats-overall' ),
359 $this->totals
360 );
361 $out .= Html::closeElement( 'tfoot' );
362
363 $out .= Html::closeElement( 'table' );
364
365 return $out;
366 } else {
367 $this->nothing = true;
368
369 return '';
370 }
371 }
372
383 private function makeGroupGroup( $item, array $cache, ?MessageGroup $parent = null, int $depth = 0 ): string {
384 if ( !is_array( $item ) ) {
385 return $this->makeGroupRow( $item, $cache, $parent, $depth );
386 }
387
388 // The first group in the array is the parent AggregateMessageGroup
389 $out = '';
390 $top = array_shift( $item );
391 $out .= $this->makeGroupRow( $top, $cache, $parent, $depth );
392
393 // Rest are children
394 foreach ( $item as $subgroup ) {
395 $out .= $this->makeGroupGroup( $subgroup, $cache, $top, $depth + 1 );
396 }
397
398 return $out;
399 }
400
405 private function makeGroupRow(
406 MessageGroup $group,
407 array $cache,
408 ?MessageGroup $parent = null,
409 int $depth = 0
410 ): string {
411 $groupId = $group->getId();
412
413 if ( $this->table->isExcluded( $group, $this->target ) ) {
414 return '';
415 }
416
417 $stats = $cache[$groupId];
418 $total = $stats[MessageGroupStats::TOTAL];
419 $translated = $stats[MessageGroupStats::TRANSLATED];
420 $fuzzy = $stats[MessageGroupStats::FUZZY];
421
422 // Quick checks to see whether filters apply
423 if ( $this->noComplete && $fuzzy === 0 && $translated === $total ) {
424 return '';
425 }
426 if ( $this->noEmpty && $translated === 0 && $fuzzy === 0 ) {
427 return '';
428 }
429
430 if ( $total === null ) {
431 $this->incomplete = true;
432 }
433
434 // Calculation of summary row values
435 if ( !$group instanceof AggregateMessageGroup &&
436 !isset( $this->statsCounted[$groupId] )
437 ) {
438 $this->totals = MessageGroupStats::multiAdd( $this->totals, $stats );
439 $this->statsCounted[$groupId] = true;
440 }
441
442 // Place any state checks like $this->incomplete above this
443 $params = $stats;
444 $params[] = $this->states[$groupId] ?? '';
445 $params[] = md5( $groupId );
446 $params[] = $this->getLanguage()->getCode();
447 $params[] = md5( $this->target );
448 $params[] = $parent ? $parent->getId() : '!';
449 $params[] = $depth;
450
451 return $this->cache->getWithSetCallback(
452 $this->cache->makeKey( __METHOD__ . '-v3', implode( '-', $params ) ),
453 $this->cache::TTL_DAY,
454 function () use ( $translated, $total, $groupId, $group, $parent, $stats, $depth ) {
455 // Any data variable read below should be part of the cache key above
456 $extra = [];
457 if ( $translated === $total ) {
458 $extra = [ 'action' => 'proofread' ];
459 }
460
461 $rowParams = [];
462 $rowParams['data-groupid'] = $groupId;
463 $rowParams['class'] = get_class( $group );
464 if ( $parent ) {
465 $rowParams['data-parentgroup'] = $parent->getId();
466 }
467 if ( $depth ) {
468 $rowParams['data-depth'] = $depth;
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...
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.