Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 177
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageGroupStatsSpecialPage
0.00% covered (danger)
0.00%
0 / 177
0.00% covered (danger)
0.00%
0 / 13
2550
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
 getDescription
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 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 / 87
0.00% covered (danger)
0.00%
0 / 1
702
 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
 isValidGroup
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 invalidTarget
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 outputIntroduction
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 formatLanguageList
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 addForm
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
20
 getGroupOptions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Statistics;
5
6use Config;
7use DeferredUpdates;
8use HTMLForm;
9use JobQueueGroup;
10use MediaWiki\Config\ServiceOptions;
11use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
12use MediaWiki\Extension\Translate\MessageProcessing\MessageGroupMetadata;
13use MediaWiki\Extension\Translate\TranslatorInterface\EntitySearch;
14use MediaWiki\Html\Html;
15use MediaWiki\Languages\LanguageNameUtils;
16use MessageGroupStats;
17use MessagePrefixMessageGroup;
18use SpecialPage;
19
20/**
21 * Implements includable special page Special:MessageGroupStats which provides
22 * translation statistics for all languages for a group.
23 *
24 * @author Niklas Laxström
25 * @author Siebrand Mazeland
26 * @license GPL-2.0-or-later
27 * @ingroup SpecialPage TranslateSpecialPage Stats
28 */
29class MessageGroupStatsSpecialPage extends SpecialPage {
30    /** Whether to hide rows which are fully translated. */
31    private bool $noComplete = true;
32    /** Whether to hide rows which are fully untranslated. */
33    private bool $noEmpty = false;
34    /** The target of stats: group id or message prefix. */
35    private string $target;
36    /** The target type of stats requested: */
37    private ?string $targetType = null;
38    private ServiceOptions $options;
39    private JobQueueGroup $jobQueueGroup;
40    private MessageGroupStatsTableFactory $messageGroupStatsTableFactory;
41    private EntitySearch $entitySearch;
42    private MessagePrefixStats $messagePrefixStats;
43    private LanguageNameUtils $languageNameUtils;
44    private MessageGroupMetadata $messageGroupMetadata;
45
46    private const GROUPS = 'group';
47    private const MESSAGES = 'messages';
48
49    private const CONSTRUCTOR_OPTIONS = [
50        'TranslateMessagePrefixStatsLimit',
51    ];
52
53    public function __construct(
54        Config $config,
55        JobQueueGroup $jobQueueGroup,
56        MessageGroupStatsTableFactory $messageGroupStatsTableFactory,
57        EntitySearch $entitySearch,
58        MessagePrefixStats $messagePrefixStats,
59        LanguageNameUtils $languageNameUtils,
60        MessageGroupMetadata $messageGroupMetadata
61    ) {
62        parent::__construct( 'MessageGroupStats' );
63        $this->options = new ServiceOptions( self::CONSTRUCTOR_OPTIONS, $config );
64        $this->jobQueueGroup = $jobQueueGroup;
65        $this->messageGroupStatsTableFactory = $messageGroupStatsTableFactory;
66        $this->entitySearch = $entitySearch;
67        $this->messagePrefixStats = $messagePrefixStats;
68        $this->languageNameUtils = $languageNameUtils;
69        $this->messageGroupMetadata = $messageGroupMetadata;
70    }
71
72    public function getDescription() {
73        // Backward compatibility for < 1.41
74        if ( version_compare( MW_VERSION, '1.41', '<' ) ) {
75            return $this->msg( 'translate-mgs-pagename' )->text();
76        }
77        return $this->msg( 'translate-mgs-pagename' );
78    }
79
80    public function isIncludable() {
81        return true;
82    }
83
84    protected function getGroupName() {
85        return 'translation';
86    }
87
88    public function execute( $par ) {
89        $request = $this->getRequest();
90
91        $purge = $request->getVal( 'action' ) === 'purge';
92        if ( $purge && !$request->wasPosted() ) {
93            LanguageStatsSpecialPage::showPurgeForm( $this->getContext() );
94            return;
95        }
96
97        $this->setHeaders();
98        $this->outputHeader();
99
100        $out = $this->getOutput();
101
102        $out->addModules( 'ext.translate.special.languagestats' );
103        $out->addModuleStyles( 'ext.translate.statstable' );
104        $out->addModuleStyles( 'ext.translate.special.groupstats' );
105
106        $params = $par ? explode( '/', $par ) : [];
107
108        if ( isset( $params[0] ) && trim( $params[0] ) ) {
109            $this->target = $params[0];
110        }
111
112        if ( isset( $params[1] ) ) {
113            $this->noComplete = (bool)$params[1];
114        }
115
116        if ( isset( $params[2] ) ) {
117            $this->noEmpty = (bool)$params[2];
118        }
119
120        // Whether the form has been submitted, only relevant if not including
121        $submitted = !$this->including() && $request->getVal( 'x' ) === 'D';
122
123        // @phan-suppress-next-line PhanCoalescingNeverNull Need to check if the property is initialized
124        $this->target = $request->getVal( self::GROUPS, $this->target ?? '' );
125        if ( $this->target !== '' ) {
126            $this->targetType = self::GROUPS;
127        } else {
128            $this->target = $request->getVal( self::MESSAGES, '' );
129            if ( $this->target !== '' ) {
130                $this->targetType = self::MESSAGES;
131            }
132        }
133
134        // Default booleans to false if the form was submitted
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        $stats = $output = null;
147        if ( $this->targetType === self::GROUPS && $this->isValidGroup( $this->target ) ) {
148            $this->outputIntroduction();
149
150            $stats = $this->loadStatistics( $this->target, MessageGroupStats::FLAG_CACHE_ONLY );
151
152            $messageGroupStatsTable = $this->messageGroupStatsTableFactory->newFromContext( $this->getContext() );
153            $output = $messageGroupStatsTable->get(
154                $stats,
155                MessageGroups::getGroup( $this->target ),
156                $this->noComplete,
157                $this->noEmpty
158            );
159
160            $incomplete = $messageGroupStatsTable->areStatsIncomplete();
161            if ( $incomplete ) {
162                $out->wrapWikiMsg(
163                    "<div class='error'>$1</div>",
164                    'translate-langstats-incomplete'
165                );
166            }
167
168            if ( $incomplete || $purge ) {
169                DeferredUpdates::addCallableUpdate( function () use ( $purge ) {
170                    // Attempt to recache on the fly the missing stats, unless a
171                    // purge was requested, because that is likely to time out.
172                    // Even though this is executed inside a deferred update, it
173                    // counts towards the maximum execution time limit. If that is
174                    // reached, or any other failure happens, no updates at all
175                    // will be written into the database, as it does only single
176                    // update at the end. Hence we always add a job too, so that
177                    // even the slower updates will get done at some point. In
178                    // regular case (no purge), the job sees that the stats are
179                    // already updated, so it is not much of an overhead.
180                    $jobParams = $this->getCacheRebuildJobParameters( $this->target );
181                    $jobParams[ 'purge' ] = $purge;
182                    $job = RebuildMessageGroupStatsJob::newJob( $jobParams );
183                    $this->jobQueueGroup->push( $job );
184
185                    // $purge is only true if request was posted
186                    if ( !$purge ) {
187                        $this->loadStatistics( $this->target );
188                    }
189                } );
190            }
191        } elseif ( $this->targetType === self::MESSAGES ) {
192            $messagesWithPrefix = $this->entitySearch->matchMessages( $this->target );
193            if ( $messagesWithPrefix ) {
194                $messageWithPrefixLimit = $this->options->get( 'TranslateMessagePrefixStatsLimit' );
195                if ( count( $messagesWithPrefix ) > $messageWithPrefixLimit ) {
196                    $out->addHTML(
197                        Html::errorBox(
198                            $this->msg( 'translate-mgs-message-prefix-limit' )
199                                ->params( $messageWithPrefixLimit )
200                                ->parse()
201                        )
202                    );
203                    return;
204                }
205
206                $stats = $this->messagePrefixStats->forAll( ...$messagesWithPrefix );
207                $messageGroupStatsTable = $this->messageGroupStatsTableFactory
208                    ->newFromContext( $this->getContext() );
209                $output = $messageGroupStatsTable->get(
210                    $stats,
211                    new MessagePrefixMessageGroup(),
212                    $this->noComplete,
213                    $this->noEmpty
214                );
215            }
216        }
217
218        if ( $output ) {
219            // If output is present, put it on the page
220            $out->addHTML( $output );
221        } elseif ( $stats !== null ) {
222            // Output not present, but stats are present. Probably an issue?
223            $out->addHTML( Html::warningBox( $this->msg( 'translate-mgs-nothing' )->parse() ) );
224        } elseif ( $submitted ) {
225            $this->invalidTarget();
226        }
227    }
228
229    private function loadStatistics( string $target, int $flags = 0 ): array {
230        return MessageGroupStats::forGroup( $target, $flags );
231    }
232
233    private function getCacheRebuildJobParameters( string $target ): array {
234        return [ 'groupid' => $target ];
235    }
236
237    private function isValidGroup( ?string $value ): bool {
238        if ( $value === null ) {
239            return false;
240        }
241
242        $group = MessageGroups::getGroup( $value );
243        if ( $group ) {
244            if ( MessageGroups::isDynamic( $group ) ) {
245                /* Dynamic groups are not listed, but it is possible to end up
246                 * on this page with a dynamic group by navigating from
247                 * translation or proofreading activity or by giving group id
248                 * of dynamic group explicitly. Ignore dynamic group to avoid
249                 * throwing exceptions later. */
250                $group = false;
251            } else {
252                $this->target = $group->getId();
253            }
254        }
255
256        return (bool)$group;
257    }
258
259    private function invalidTarget(): void {
260        $this->getOutput()->wrapWikiMsg(
261            "<div class='error'>$1</div>",
262            [ 'translate-mgs-invalid-group', $this->target ]
263        );
264    }
265
266    private function outputIntroduction(): void {
267        $priorityLangs = $this->messageGroupMetadata->get( $this->target, 'prioritylangs' );
268        if ( $priorityLangs ) {
269            $languagesFormatted = $this->formatLanguageList( explode( ',', $priorityLangs ) );
270            $hasPriorityForce = $this->messageGroupMetadata->get( $this->target, 'priorityforce' ) === 'on';
271            if ( $hasPriorityForce ) {
272                $this->getOutput()->addWikiMsg( 'tpt-priority-languages-force', $languagesFormatted );
273            } else {
274                $this->getOutput()->addWikiMsg( 'tpt-priority-languages', $languagesFormatted );
275            }
276        }
277    }
278
279    private function formatLanguageList( array $codes ): string {
280        foreach ( $codes as &$value ) {
281            $value = $this->languageNameUtils->getLanguageName( $value, $this->getLanguage()->getCode() )
282                . $this->msg( 'word-separator' )->plain()
283                . $this->msg( 'parentheses', $value )->plain();
284        }
285
286        return $this->getLanguage()->listToText( $codes );
287    }
288
289    private function addForm(): void {
290        $formDescriptor = [
291            'select' => [
292                'type' => 'select',
293                'name' => self::GROUPS,
294                'id' => self::GROUPS,
295                'label' => $this->msg( 'translate-mgs-group' )->text(),
296                'options' => $this->getGroupOptions(),
297                'default' => $this->targetType === self::GROUPS ? $this->target : null,
298                'cssclass' => 'message-group-selector'
299            ],
300            'input' => [
301                'type' => 'text',
302                'name' => self::MESSAGES,
303                'id' => self::MESSAGES,
304                'label' => $this->msg( 'translate-mgs-prefix' )->text(),
305                'default' => $this->targetType === self::MESSAGES ? $this->target : null,
306                'cssclass' => 'message-prefix-selector'
307            ],
308            'nocomplete-check' => [
309                'type' => 'check',
310                'name' => 'suppresscomplete',
311                'id' => 'suppresscomplete',
312                'label' => $this->msg( 'translate-mgs-nocomplete' )->text(),
313                'default' => $this->noComplete,
314            ],
315            'noempty-check' => [
316                'type' => 'check',
317                'name' => 'suppressempty',
318                'id' => 'suppressempty',
319                'label' => $this->msg( 'translate-mgs-noempty' )->text(),
320                'default' => $this->noEmpty,
321            ]
322        ];
323
324        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
325
326        /* Since these pages are in the tabgroup with Special:Translate,
327         * it makes sense to retain the selected group/language parameter
328         * on post requests even when not relevant to the current page. */
329        $val = $this->getRequest()->getVal( 'language' );
330        if ( $val !== null ) {
331            $htmlForm->addHiddenField( 'language', $val );
332        }
333
334        $htmlForm
335            ->addHiddenField( 'x', 'D' ) // To detect submission
336            ->setMethod( 'get' )
337            ->setId( 'mw-message-group-stats-form' )
338            ->setSubmitTextMsg( 'translate-mgs-submit' )
339            ->setWrapperLegendMsg( 'translate-mgs-fieldset' )
340            ->prepareForm()
341            ->displayForm( false );
342    }
343
344    /** Creates a simple message group options. */
345    private function getGroupOptions(): array {
346        $options = [ '' => null ];
347        $groups = MessageGroups::getAllGroups();
348
349        foreach ( $groups as $id => $class ) {
350            if ( MessageGroups::getGroup( $id )->exists() ) {
351                $options[$class->getLabel()] = $id;
352            }
353        }
354
355        return $options;
356    }
357}