Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 265
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslateSpecialPage
0.00% covered (danger)
0.00%
0 / 265
0.00% covered (danger)
0.00%
0 / 15
1892
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
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 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 setup
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
182
 tuxSettingsForm
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 messageSelector
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
6
 tuxGroupSelector
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 tuxLanguageSelector
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
6
 tuxGroupSubscription
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 tuxGroupDescription
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupDescription
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 tuxGroupWarning
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 tuxWorkflowSelector
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tabify
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\TranslatorInterface;
5
6use AggregateMessageGroup;
7use Language;
8use MediaWiki\Extension\Translate\HookRunner;
9use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
10use MediaWiki\Extension\Translate\Utilities\Utilities;
11use MediaWiki\Html\Html;
12use MediaWiki\Languages\LanguageFactory;
13use MediaWiki\Languages\LanguageNameUtils;
14use MediaWiki\Logger\LoggerFactory;
15use MediaWiki\MediaWikiServices;
16use MessageGroup;
17use Psr\Log\LoggerInterface;
18use Skin;
19use SpecialPage;
20
21/**
22 * Implements the core of Translate extension - a special page which shows
23 * a list of messages in a format defined by Tasks.
24 *
25 * @author Niklas Laxström
26 * @author Siebrand Mazeland
27 * @license GPL-2.0-or-later
28 * @ingroup SpecialPage TranslateSpecialPage
29 */
30class TranslateSpecialPage extends SpecialPage {
31    private ?MessageGroup $group = null;
32    private array $options = [];
33    private Language $contentLanguage;
34    private LanguageFactory $languageFactory;
35    private LanguageNameUtils $languageNameUtils;
36    private HookRunner $hookRunner;
37    private LoggerInterface $logger;
38
39    public function __construct(
40        Language $contentLanguage,
41        LanguageFactory $languageFactory,
42        LanguageNameUtils $languageNameUtils,
43        HookRunner $hookRunner
44    ) {
45        parent::__construct( 'Translate' );
46        $this->contentLanguage = $contentLanguage;
47        $this->languageFactory = $languageFactory;
48        $this->languageNameUtils = $languageNameUtils;
49        $this->hookRunner = $hookRunner;
50        $this->logger = LoggerFactory::getInstance( 'Translate' );
51    }
52
53    public function doesWrites() {
54        return true;
55    }
56
57    protected function getGroupName() {
58        return 'translation';
59    }
60
61    /** @inheritDoc */
62    public function execute( $parameters ) {
63        $out = $this->getOutput();
64        $out->addModuleStyles( [
65            'ext.translate.special.translate.styles',
66            'jquery.uls.grid',
67            'mediawiki.ui.button'
68        ] );
69
70        $this->setHeaders();
71
72        $this->setup( $parameters );
73
74        // Redirect old export URLs to Special:ExportTranslations
75        if ( $this->getRequest()->getText( 'taction' ) === 'export' ) {
76            $exportPage = SpecialPage::getTitleFor( 'ExportTranslations' );
77            $out->redirect( $exportPage->getLocalURL( $this->options ) );
78        }
79
80        $out->addModules( 'ext.translate.special.translate' );
81        $out->addJsConfigVars(
82            'wgTranslateLanguages',
83            Utilities::getLanguageNames( LanguageNameUtils::AUTONYMS )
84        );
85
86        $out->addHTML( Html::openElement( 'div', [
87            'class' => 'grid ext-translate-container',
88        ] ) );
89
90        $out->addHTML( $this->tuxSettingsForm() );
91        $out->addHTML( $this->messageSelector() );
92
93        $table = new MessageTable( $this->getContext(), $this->group, $this->options['language'] );
94        $output = $table->fullTable();
95
96        $out->addHTML( $output );
97        $out->addHTML( Html::closeElement( 'div' ) );
98    }
99
100    private function setup( ?string $parameters ): void {
101        $request = $this->getRequest();
102
103        $defaults = [
104            'language' => $this->getLanguage()->getCode(),
105            'group' => '!additions',
106        ];
107
108        // Dump everything here
109        $nonDefaults = [];
110        $parameters = array_map( 'trim', explode( ';', (string)$parameters ) );
111
112        foreach ( $parameters as $_ ) {
113            if ( $_ === '' ) {
114                continue;
115            }
116
117            if ( str_contains( $_, '=' ) ) {
118                [ $key, $value ] = array_map( 'trim', explode( '=', $_, 2 ) );
119            } else {
120                $key = 'group';
121                $value = $_;
122            }
123
124            if ( isset( $defaults[$key] ) ) {
125                $nonDefaults[$key] = $value;
126            }
127        }
128
129        foreach ( array_keys( $defaults ) as $key ) {
130            $value = $request->getVal( $key );
131            if ( is_string( $value ) ) {
132                $nonDefaults[$key] = $value;
133            }
134        }
135
136        $this->hookRunner->onTranslateGetSpecialTranslateOptions( $defaults, $nonDefaults );
137
138        $this->options = $nonDefaults + $defaults;
139        $this->group = MessageGroups::getGroup( $this->options['group'] );
140        if ( $this->group ) {
141            $this->options['group'] = $this->group->getId();
142        } else {
143            $this->group = MessageGroups::getGroup( $defaults['group'] );
144            if (
145                isset( $nonDefaults['group'] ) &&
146                str_starts_with( $nonDefaults['group'], 'page-' ) &&
147                !str_contains( $nonDefaults['group'], '+' )
148            ) {
149                // https://phabricator.wikimedia.org/T320220
150                $this->logger->debug(
151                    "[Special:Translate] Requested group {groupId} doesn't exist.",
152                    [ 'groupId' => $nonDefaults['group'] ]
153                );
154            }
155        }
156
157        if ( !$this->languageNameUtils->isKnownLanguageTag( $this->options['language'] ) ) {
158            $this->options['language'] = $defaults['language'];
159        }
160
161        if ( MessageGroups::isDynamic( $this->group ) ) {
162            // @phan-suppress-next-line PhanUndeclaredMethod
163            $this->group->setLanguage( $this->options['language'] );
164        }
165    }
166
167    private function tuxSettingsForm(): string {
168        $noJs = Html::errorBox(
169            $this->msg( 'tux-nojs' )->escaped(),
170            '',
171            'tux-nojs'
172        );
173
174        $attrs = [ 'class' => 'row tux-editor-header' ];
175        $selectors = $this->tuxGroupSelector() .
176            $this->tuxLanguageSelector() .
177            $this->tuxGroupSubscription() .
178            $this->tuxGroupDescription() .
179            $this->tuxWorkflowSelector() .
180            $this->tuxGroupWarning();
181
182        return Html::rawElement( 'div', $attrs, $selectors ) . $noJs;
183    }
184
185    private function messageSelector(): string {
186        $output = Html::openElement( 'div', [ 'class' => 'row tux-messagetable-header hide' ] );
187        $output .= Html::openElement( 'div', [ 'class' => 'nine columns' ] );
188        $output .= Html::openElement( 'ul', [ 'class' => 'row tux-message-selector' ] );
189        $userId = $this->getUser()->getId();
190        $tabs = [
191            'all' => '',
192            'untranslated' => '!translated',
193            'outdated' => 'fuzzy',
194            'translated' => 'translated',
195            'unproofread' => "translated|!reviewer:$userId|!last-translator:$userId",
196        ];
197
198        foreach ( $tabs as $tab => $filter ) {
199            // Possible classes and messages, for grepping:
200            // tux-tab-all
201            // tux-tab-untranslated
202            // tux-tab-outdated
203            // tux-tab-translated
204            // tux-tab-unproofread
205            $tabClass = "tux-tab-$tab";
206            $link = Html::element( 'a', [ 'href' => '#' ], $this->msg( $tabClass )->text() );
207            $output .= Html::rawElement( 'li', [
208                'class' => 'column ' . $tabClass,
209                'data-filter' => $filter,
210                'data-title' => $tab,
211            ], $link );
212        }
213
214        // Check boxes for the "more" tab.
215        $container = Html::openElement( 'ul', [ 'class' => 'column tux-message-selector' ] );
216        $container .= Html::rawElement( 'li',
217            [ 'class' => 'column' ],
218            Html::element( 'input', [
219                'type' => 'checkbox', 'name' => 'optional', 'value' => '1',
220                'checked' => false,
221                'id' => 'tux-option-optional',
222                'data-filter' => 'optional'
223            ] ) . "\u{00A0}" . Html::label(
224                $this->msg( 'tux-message-filter-optional-messages-label' )->text(),
225                'tux-option-optional'
226            )
227        );
228
229        $container .= Html::closeElement( 'ul' );
230        $output .= Html::openElement( 'li', [ 'class' => 'column more' ] ) .
231            $this->msg( 'ellipsis' )->escaped() .
232            $container .
233            Html::closeElement( 'li' );
234
235        $output .= Html::closeElement( 'ul' );
236        $output .= Html::closeElement( 'div' ); // close nine columns
237        $output .= Html::openElement( 'div', [ 'class' => 'three columns' ] );
238        $output .= Html::rawElement(
239            'div',
240            [ 'class' => 'tux-message-filter-wrapper' ],
241            Html::element( 'input', [
242                'class' => 'tux-message-filter-box',
243                'type' => 'search',
244                'placeholder' => $this->msg( 'tux-message-filter-placeholder' )->text()
245            ] )
246        );
247
248        // close three columns and the row
249        $output .= Html::closeElement( 'div' ) . Html::closeElement( 'div' );
250
251        return $output;
252    }
253
254    private function tuxGroupSelector(): string {
255        $groupClass = [ 'grouptitle', 'grouplink' ];
256        if ( $this->group instanceof AggregateMessageGroup ) {
257            $groupClass[] = 'tux-breadcrumb__item--aggregate';
258        }
259
260        // @todo FIXME The selector should have expanded parent-child lists
261        return Html::openElement( 'div', [
262            'class' => 'eight columns tux-breadcrumb',
263            'data-language' => $this->options['language'],
264        ] ) .
265            Html::element( 'span',
266                [ 'class' => 'grouptitle' ],
267                $this->msg( 'translate-msggroupselector-projects' )->text()
268            ) .
269            Html::element( 'span',
270                [ 'class' => 'grouptitle grouplink tux-breadcrumb__item--aggregate' ],
271                $this->msg( 'translate-msggroupselector-search-all' )->text()
272            ) .
273            Html::element( 'span',
274                [
275                    'class' => $groupClass,
276                    'data-msggroupid' => $this->group->getId(),
277                ],
278                $this->group->getLabel( $this->getContext() )
279            ) .
280            Html::closeElement( 'div' );
281    }
282
283    private function tuxLanguageSelector(): string {
284        global $wgTranslateDocumentationLanguageCode;
285
286        if ( $this->options['language'] === $wgTranslateDocumentationLanguageCode ) {
287            $targetLangName = $this->msg( 'translate-documentation-language' )->text();
288            $targetLanguage = $this->contentLanguage;
289        } else {
290            $targetLangName = $this->languageNameUtils->getLanguageName( $this->options['language'] );
291            $targetLanguage = $this->languageFactory->getLanguage( $this->options['language'] );
292        }
293
294        $label = Html::element( 'span', [], $this->msg( 'tux-languageselector' )->text() );
295
296        $languageIcon = Html::element(
297            'span',
298            [ 'class' => 'ext-translate-language-icon' ]
299        );
300
301        $targetLanguageName = Html::element(
302            'span',
303            [
304                'class' => 'ext-translate-target-language',
305                'dir' => $targetLanguage->getDir(),
306                'lang' => $targetLanguage->getHtmlCode()
307            ],
308            $targetLangName
309        );
310
311        $expandIcon = Html::element(
312            'span',
313            [ 'class' => 'ext-translate-language-selector-expand' ]
314        );
315
316        $value = Html::rawElement(
317            'span',
318            [
319                'class' => 'uls mw-ui-button',
320                'tabindex' => 0,
321                'title' => $this->msg( 'tux-select-target-language' )->text()
322            ],
323            $languageIcon . $targetLanguageName . $expandIcon
324        );
325
326        return Html::rawElement(
327            'div',
328            [ 'class' => 'four columns ext-translate-language-selector' ],
329            "$label $value"
330        );
331    }
332
333    private function tuxGroupSubscription(): string {
334        return Html::rawElement(
335            'div',
336            [ 'class' => 'twelve columns tux-watch-group' ]
337        );
338    }
339
340    private function tuxGroupDescription(): string {
341        // Initialize an empty warning box to be filled client-side.
342        return Html::rawElement(
343            'div',
344            [ 'class' => 'twelve columns description' ],
345            $this->getGroupDescription( $this->group )
346        );
347    }
348
349    private function getGroupDescription( MessageGroup $group ): string {
350        $description = $group->getDescription( $this->getContext() );
351        return $description === null ?
352            '' : $this->getOutput()->parseAsInterface( $description );
353    }
354
355    private function tuxGroupWarning(): string {
356        if ( $this->options['group'] === '' ) {
357            return Html::warningBox(
358                $this->msg( 'tux-translate-page-no-such-group' )->parse(),
359                'tux-group-warning twelve column'
360            );
361        }
362
363        return '';
364    }
365
366    private function tuxWorkflowSelector(): string {
367        return Html::element( 'div', [ 'class' => 'tux-workflow twelve columns' ] );
368    }
369
370    /**
371     * Adds the task-based tabs on Special:Translate and few other special pages.
372     * Hook: SkinTemplateNavigation::Universal
373     */
374    public static function tabify( Skin $skin, array &$tabs ): bool {
375        $title = $skin->getTitle();
376        if ( !$title->isSpecialPage() ) {
377            return true;
378        }
379        [ $alias, $sub ] = MediaWikiServices::getInstance()
380            ->getSpecialPageFactory()->resolveAlias( $title->getText() );
381
382        $pagesInGroup = [ 'Translate', 'LanguageStats', 'MessageGroupStats', 'ExportTranslations' ];
383        if ( !in_array( $alias, $pagesInGroup, true ) ) {
384            return true;
385        }
386
387        // Extract subpage syntax, otherwise the values are not passed forward
388        $params = [];
389        if ( $sub !== null && trim( $sub ) !== '' ) {
390            if ( $alias === 'Translate' || $alias === 'MessageGroupStats' ) {
391                $params['group'] = $sub;
392            } elseif ( $alias === 'LanguageStats' ) {
393                // Breaks if additional parameters besides language are code provided
394                $params['language'] = $sub;
395            }
396        }
397
398        $request = $skin->getRequest();
399        // However, query string params take precedence
400        $params['language'] = $request->getRawVal( 'language' ) ?? '';
401        $params['group'] = $request->getRawVal( 'group' ) ?? '';
402
403        // Remove empty values from params
404        $params = array_filter( $params, static function ( string $param ) {
405            return $param !== '';
406        } );
407
408        $translate = SpecialPage::getTitleFor( 'Translate' );
409        $languageStatistics = SpecialPage::getTitleFor( 'LanguageStats' );
410        $messageGroupStatistics = SpecialPage::getTitleFor( 'MessageGroupStats' );
411
412        // Clear the special page tab that might be there already
413        $tabs['namespaces'] = [];
414
415        $tabs['namespaces']['translate'] = [
416            'text' => wfMessage( 'translate-taction-translate' )->text(),
417            'href' => $translate->getLocalURL( $params ),
418            'class' => 'tux-tab',
419        ];
420
421        if ( $alias === 'Translate' ) {
422            $tabs['namespaces']['translate']['class'] .= ' selected';
423        }
424
425        $tabs['views']['lstats'] = [
426            'text' => wfMessage( 'translate-taction-lstats' )->text(),
427            'href' => $languageStatistics->getLocalURL( $params ),
428            'class' => 'tux-tab',
429        ];
430        if ( $alias === 'LanguageStats' ) {
431            $tabs['views']['lstats']['class'] .= ' selected';
432        }
433
434        $tabs['views']['mstats'] = [
435            'text' => wfMessage( 'translate-taction-mstats' )->text(),
436            'href' => $messageGroupStatistics->getLocalURL( $params ),
437            'class' => 'tux-tab',
438        ];
439
440        if ( $alias === 'MessageGroupStats' ) {
441            $tabs['views']['mstats']['class'] .= ' selected';
442        }
443
444        $tabs['views']['export'] = [
445            'text' => wfMessage( 'translate-taction-export' )->text(),
446            'href' => SpecialPage::getTitleFor( 'ExportTranslations' )->getLocalURL( $params ),
447            'class' => 'tux-tab',
448        ];
449
450        return true;
451    }
452}