Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
MessageGroupStatsSpecialPage.php
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Statistics;
5
6use DeferredUpdates;
7use Html;
8use HTMLForm;
9use JobQueueGroup;
13use SpecialPage;
16
26class MessageGroupStatsSpecialPage extends SpecialPage {
28 private $table;
30 private $targetValueName = [ 'group' ];
32 private $totals;
37 private $nothing = false;
42 private $incomplete = false;
47 private $noComplete = true;
52 private $noEmpty = false;
54 private $target;
59 private $purge;
61 private $states;
63 private $progressStatsTableFactory;
64 private $names;
65 private $translate;
67 private $numberOfShownLanguages;
69 private $jobQueueGroup;
70
71 // region SpecialPage overrides
72
73 public function __construct(
74 ProgressStatsTableFactory $progressStatsTableFactory,
75 JobQueueGroup $jobQueueGroup
76 ) {
77 parent::__construct( 'MessageGroupStats' );
78 $this->progressStatsTableFactory = $progressStatsTableFactory;
79 $this->jobQueueGroup = $jobQueueGroup;
80 $this->totals = MessageGroupStats::getEmptyStats();
81 }
82
83 public function getDescription() {
84 return $this->msg( 'translate-mgs-pagename' )->text();
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 $request = $this->getRequest();
97
98 $this->purge = $request->getVal( 'action' ) === 'purge';
99 if ( $this->purge && !$request->wasPosted() ) {
100 LanguageStatsSpecialPage::showPurgeForm( $this->getContext() );
101 return;
102 }
103
104 $this->table = $this->progressStatsTableFactory->newFromContext( $this->getContext() );
105
106 $this->setHeaders();
107 $this->outputHeader();
108
109 $out = $this->getOutput();
110
111 $out->addModules( 'ext.translate.special.languagestats' );
112 $out->addModuleStyles( 'ext.translate.statstable' );
113
114 $params = $par ? explode( '/', $par ) : [];
115
116 if ( isset( $params[0] ) && trim( $params[0] ) ) {
117 $this->target = $params[0];
118 }
119
120 if ( isset( $params[1] ) ) {
121 $this->noComplete = (bool)$params[1];
122 }
123
124 if ( isset( $params[2] ) ) {
125 $this->noEmpty = (bool)$params[2];
126 }
127
128 // Whether the form has been submitted, only relevant if not including
129 $submitted = !$this->including() && $request->getVal( 'x' ) === 'D';
130
131 // Default booleans to false if the form was submitted
132 foreach ( $this->targetValueName as $key ) {
133 $this->target = $request->getVal( $key, $this->target );
134 }
135 $this->noComplete = $request->getBool(
136 'suppresscomplete',
137 $this->noComplete && !$submitted
138 );
139 $this->noEmpty = $request->getBool( 'suppressempty', $this->noEmpty && !$submitted );
140
141 if ( !$this->including() ) {
142 $out->addHelpLink( 'Help:Extension:Translate/Statistics_and_reporting' );
143 $this->addForm();
144 }
145
146 if ( $this->isValidValue( $this->target ) ) {
147 $this->outputIntroduction();
148
149 $stats = $this->loadStatistics( $this->target, MessageGroupStats::FLAG_CACHE_ONLY );
150 $output = $this->getTable( $stats );
151 if ( $this->incomplete ) {
152 $out->wrapWikiMsg(
153 "<div class='error'>$1</div>",
154 'translate-langstats-incomplete'
155 );
156 }
157
158 if ( $this->incomplete || $this->purge ) {
159 DeferredUpdates::addCallableUpdate( function () {
160 // Attempt to recache on the fly the missing stats, unless a
161 // purge was requested, because that is likely to time out.
162 // Even though this is executed inside a deferred update, it
163 // counts towards the maximum execution time limit. If that is
164 // reached, or any other failure happens, no updates at all
165 // will be written into the database, as it does only single
166 // update at the end. Hence we always add a job too, so that
167 // even the slower updates will get done at some point. In
168 // regular case (no purge), the job sees that the stats are
169 // already updated, so it is not much of an overhead.
170 $jobParams = $this->getCacheRebuildJobParameters( $this->target );
171 $jobParams[ 'purge' ] = $this->purge;
172 $job = MessageGroupStatsRebuildJob::newJob( $jobParams );
173 $this->jobQueueGroup->push( $job );
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
190 // endregion
191
192 private function loadStatistics( string $target, int $flags = 0 ): array {
193 return MessageGroupStats::forGroup( $target, $flags );
194 }
195
196 private function getCacheRebuildJobParameters( string $target ): array {
197 return [ 'groupid' => $target ];
198 }
199
200 private function isValidValue( ?string $value ): bool {
201 if ( $value === null ) {
202 return false;
203 }
204
205 $group = MessageGroups::getGroup( $value );
206 if ( $group ) {
207 if ( MessageGroups::isDynamic( $group ) ) {
208 /* Dynamic groups are not listed, but it is possible to end up
209 * on this page with a dynamic group by navigating from
210 * translation or proofreading activity or by giving group id
211 * of dynamic group explicitly. Ignore dynamic group to avoid
212 * throwing exceptions later. */
213 $group = false;
214 } else {
215 $this->target = $group->getId();
216 }
217 }
218
219 return (bool)$group;
220 }
221
222 private function invalidTarget(): void {
223 $this->getOutput()->wrapWikiMsg(
224 "<div class='error'>$1</div>",
225 [ 'translate-mgs-invalid-group', $this->target ]
226 );
227 }
228
229 private function outputIntroduction(): void {
230 $priorityLangs = TranslateMetadata::get( $this->target, 'prioritylangs' );
231 if ( $priorityLangs ) {
232 $hasPriorityForce = TranslateMetadata::get( $this->target, 'priorityforce' ) === 'on';
233 if ( $hasPriorityForce ) {
234 $this->getOutput()->addWikiMsg( 'tpt-priority-languages-force', $priorityLangs );
235 } else {
236 $this->getOutput()->addWikiMsg( 'tpt-priority-languages', $priorityLangs );
237 }
238 }
239 }
240
242 private function addWorkflowStatesColumn(): void {
243 global $wgTranslateWorkflowStates;
244
245 if ( $wgTranslateWorkflowStates ) {
246 $this->states = $this->getWorkflowStates();
247
248 // An array where keys are state names and values are numbers
249 $this->table->addExtraColumn( $this->msg( 'translate-stats-workflow' ) );
250 }
251 }
252
254 private function getWorkflowStateCell( string $language ): string {
255 // This will be set by addWorkflowStatesColumn if needed
256 if ( !isset( $this->states ) ) {
257 return '';
258 }
259
260 return $this->table->makeWorkflowStateCell(
261 $this->states[$language] ?? null,
262 MessageGroups::getGroup( $this->target ),
263 $language
264 );
265 }
266
267 private function addForm(): void {
268 $formDescriptor = [
269 'select' => [
270 'type' => 'select',
271 'name' => 'group',
272 'id' => 'group',
273 'label' => $this->msg( 'translate-mgs-group' )->text(),
274 'options' => $this->getGroupOptions(),
275 'default' => $this->target
276 ],
277 'nocomplete-check' => [
278 'type' => 'check',
279 'name' => 'suppresscomplete',
280 'id' => 'suppresscomplete',
281 'label' => $this->msg( 'translate-mgs-nocomplete' )->text(),
282 'default' => $this->noComplete,
283 ],
284 'noempty-check' => [
285 'type' => 'check',
286 'name' => 'suppressempty',
287 'id' => 'suppressempty',
288 'label' => $this->msg( 'translate-mgs-noempty' )->text(),
289 'default' => $this->noEmpty,
290 ]
291 ];
292
293 $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
294
295 /* Since these pages are in the tabgroup with Special:Translate,
296 * it makes sense to retain the selected group/language parameter
297 * on post requests even when not relevant to the current page. */
298 $val = $this->getRequest()->getVal( 'language' );
299 if ( $val !== null ) {
300 $htmlForm->addHiddenField( 'language', $val );
301 }
302
303 $htmlForm
304 ->addHiddenField( 'x', 'D' ) // To detect submission
305 ->setMethod( 'get' )
306 ->setSubmitTextMsg( 'translate-mgs-submit' )
307 ->setWrapperLegendMsg( 'translate-mgs-fieldset' )
308 ->prepareForm()
309 ->displayForm( false );
310 }
311
312 private function getTable( array $stats ): string {
313 $table = $this->table;
314
315 $this->addWorkflowStatesColumn();
316 $out = '';
317
318 $this->numberOfShownLanguages = 0;
319 $languages = array_keys(
320 TranslateUtils::getLanguageNames( $this->getLanguage()->getCode() )
321 );
322 sort( $languages );
323 $this->filterPriorityLangs( $languages, $this->target, $stats );
324 foreach ( $languages as $code ) {
325 if ( $table->isExcluded( $this->target, $code ) ) {
326 continue;
327 }
328 $out .= $this->makeRow( $code, $stats );
329 }
330
331 if ( $out ) {
332 $table->setMainColumnHeader( $this->msg( 'translate-mgs-column-language' ) );
333 $out = $table->createHeader() . "\n" . $out;
334 $out .= Html::closeElement( 'tbody' );
335
336 $out .= Html::openElement( 'tfoot' );
337 $out .= $table->makeTotalRow(
338 $this->msg( 'translate-mgs-totals' )
339 ->numParams( $this->numberOfShownLanguages ),
340 $this->totals
341 );
342 $out .= Html::closeElement( 'tfoot' );
343
344 $out .= Html::closeElement( 'table' );
345
346 return $out;
347 } else {
348 $this->nothing = true;
349
350 return '';
351 }
352 }
353
359 private function filterPriorityLangs( array &$languages, string $group, array $cache ): void {
360 $filterLangs = TranslateMetadata::get( $group, 'prioritylangs' );
361 if ( $filterLangs === false || strlen( $filterLangs ) === 0 ) {
362 // No restrictions, keep everything
363 return;
364 }
365 $filter = array_flip( explode( ',', $filterLangs ) );
366 foreach ( $languages as $id => $code ) {
367 if ( isset( $filter[$code] ) ) {
368 continue;
369 }
370 $translated = $cache[$code][1];
371 if ( $translated === 0 ) {
372 unset( $languages[$id] );
373 }
374 }
375 }
376
377 private function makeRow( string $code, array $cache ): string {
378 $stats = $cache[$code];
379 $total = $stats[MessageGroupStats::TOTAL];
380 $translated = $stats[MessageGroupStats::TRANSLATED];
381 $fuzzy = $stats[MessageGroupStats::FUZZY];
382
383 if ( $total === null ) {
384 $this->incomplete = true;
385 $extra = [];
386 } else {
387 if ( $this->noComplete && $fuzzy === 0 && $translated === $total ) {
388 return '';
389 }
390
391 if ( $this->noEmpty && $translated === 0 && $fuzzy === 0 ) {
392 return '';
393 }
394
395 // Skip below 2% if "don't show without translations" is checked.
396 if ( $this->noEmpty && ( $translated / $total ) < 0.02 ) {
397 return '';
398 }
399
400 if ( $translated === $total ) {
401 $extra = [ 'action' => 'proofread' ];
402 } else {
403 $extra = [];
404 }
405 }
406 $this->numberOfShownLanguages += 1;
407 $this->totals = MessageGroupStats::multiAdd( $this->totals, $stats );
408
409 $rowParams = [];
410 if ( $this->numberOfShownLanguages % 2 === 0 ) {
411 $rowParams[ 'class' ] = 'tux-statstable-even';
412 }
413
414 $out = "\t" . Html::openElement( 'tr', $rowParams );
415 $out .= "\n\t\t" . $this->getMainColumnCell( $code, $extra );
416 $out .= $this->table->makeNumberColumns( $stats );
417 $out .= $this->getWorkflowStateCell( $code );
418
419 $out .= "\n\t" . Html::closeElement( 'tr' ) . "\n";
420
421 return $out;
422 }
423
424 private function getMainColumnCell( string $code, array $params ): string {
425 if ( !isset( $this->names ) ) {
426 $this->names = TranslateUtils::getLanguageNames( $this->getLanguage()->getCode() );
427 $this->translate = SpecialPage::getTitleFor( 'Translate' );
428 }
429
430 $queryParameters = $params + [
431 'group' => $this->target,
432 'language' => $code
433 ];
434
435 if ( isset( $this->names[$code] ) ) {
436 $text = "$code: {$this->names[$code]}";
437 } else {
438 $text = $code;
439 }
440 $link = $this->getLinkRenderer()->makeKnownLink(
441 $this->translate,
442 $text,
443 [],
444 $queryParameters
445 );
446
447 return Html::rawElement( 'td', [], $link );
448 }
449
450 private function getWorkflowStates(): array {
451 $db = wfGetDB( DB_REPLICA );
452 $res = $db->select(
453 'translate_groupreviews',
454 [ 'tgr_state', 'tgr_lang' ],
455 [ 'tgr_group' => $this->target ],
456 __METHOD__
457 );
458
459 $states = [];
460 foreach ( $res as $row ) {
461 $states[$row->tgr_lang] = $row->tgr_state;
462 }
463
464 return $states;
465 }
466
468 private function getGroupOptions(): array {
469 $options = [];
470 $groups = MessageGroups::getAllGroups();
471
472 foreach ( $groups as $id => $class ) {
473 if ( MessageGroups::getGroup( $id )->exists() ) {
474 $options[$class->getLabel()] = $id;
475 }
476 }
477
478 return $options;
479 }
480}
Implements includable special page Special:MessageGroupStats which provides translation statistics fo...
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.