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