Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 235
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
LanguageStatsSpecialPage
0.00% covered (danger)
0.00%
0 / 235
0.00% covered (danger)
0.00%
0 / 15
3306
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 isIncludable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
420
 loadStatistics
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheRebuildJobParameters
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isValidValue
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 invalidTarget
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 showPurgeForm
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 addForm
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
6
 outputIntroduction
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 getWorkflowStateCell
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getTable
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
42
 makeGroupGroup
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 makeGroupRow
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
240
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Statistics;
5
6use AggregateMessageGroup;
7use JobQueueGroup;
8use MediaWiki\Cache\LinkBatchFactory;
9use MediaWiki\Context\DerivativeContext;
10use MediaWiki\Context\IContextSource;
11use MediaWiki\Deferred\DeferredUpdates;
12use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroupReviewStore;
13use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
14use MediaWiki\Extension\Translate\Utilities\Utilities;
15use MediaWiki\Html\Html;
16use MediaWiki\HTMLForm\HTMLForm;
17use MediaWiki\Languages\LanguageNameUtils;
18use MediaWiki\SpecialPage\SpecialPage;
19use MessageGroup;
20use ObjectCacheFactory;
21use Wikimedia\ObjectCache\BagOStuff;
22use WikiPageMessageGroup;
23
24/**
25 * Implements includable special page Special:LanguageStats which provides
26 * translation statistics for all defined message groups.
27 *
28 * Loosely based on the statistics code in phase3/maintenance/language
29 *
30 * Use {{Special:LanguageStats/nl/1}} to show for 'nl' and suppress completely
31 * translated groups.
32 *
33 * @author Siebrand Mazeland
34 * @author Niklas Laxström
35 * @license GPL-2.0-or-later
36 * @ingroup SpecialPage TranslateSpecialPage Stats
37 */
38class LanguageStatsSpecialPage extends SpecialPage {
39    private LanguageNameUtils $languageNameUtils;
40    private StatsTable $table;
41    private array $targetValueName = [ 'code', 'language' ];
42    /** Most of the displayed numbers added together at the bottom of the table. */
43    private array $totals;
44    /** Flag to set if nothing to show. */
45    private bool $nothing = false;
46    /** Flag to set if not all numbers are available. */
47    private bool $incomplete = false;
48    /** Whether to hide rows which are fully translated. */
49    private bool $noComplete = true;
50    /** Whether to hide rows which are fully untranslated. */
51    private bool $noEmpty = false;
52    /** The target of stats, language code or group id. */
53    private string $target;
54    /** Whether to regenerate stats. Activated by action=purge in query params. */
55    private bool $purge;
56    /**
57     * Helper variable to avoid overcounting message groups that appear
58     * multiple times in the list with different parents. Aggregate message
59     * group stats are always excluded from totals.
60     */
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
190    /**
191     * Get stats.
192     * @param string $target For which target to get stats
193     * @param int $flags See MessageGroupStats for possible flags
194     * @return array[]
195     */
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
204    /** Return true if language exist in the list of allowed languages or false otherwise. */
205    private function isValidValue( string $value ): bool {
206        $langs = $this->languageNameUtils->getLanguageNames();
207
208        return isset( $langs[$value] );
209    }
210
211    /** Called when the target is unknown. */
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
239    /** HTMLForm for the top form rendering. */
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
288    /** Output something helpful to guide the confused user. */
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
373    /**
374     * Creates a html table row for given (top-level) message group.
375     * If $item is an array, meaning that the first group is an
376     * AggregateMessageGroup and the latter are its children, it will recurse
377     * and create rows for them too.
378     * @param MessageGroup|MessageGroup[] $item
379     * @param array $cache Cache as returned by MessageGroupStats::forLanguage
380     * @param MessageGroup|null $parent MessageGroup (do not use, used internally only)
381     * @param int $depth The depth level of nesting. Top level is zero.
382     */
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
401    /**
402     * Actually creates the table for single message group, unless it
403     * is in the exclusion list or hidden by filters.
404     */
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}