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