Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 187
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExportTranslationsSpecialPage
0.00% covered (danger)
0.00%
0 / 187
0.00% covered (danger)
0.00%
0 / 12
2162
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 outputForm
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupOptions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getLanguageOptions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getFormatOptions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 checkInput
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
156
 doExport
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
156
 setupCollection
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 sendExportHeaders
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 exportCSV
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Synchronization;
5
6use FileBasedMessageGroup;
7use LogicException;
8use MediaWiki\Extension\Translate\FileFormatSupport\GettextFormat;
9use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
10use MediaWiki\Extension\Translate\MessageLoading\MessageCollection;
11use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
12use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage;
13use MediaWiki\Extension\Translate\Utilities\Utilities;
14use MediaWiki\Html\Html;
15use MediaWiki\HTMLForm\HTMLForm;
16use MediaWiki\Language\FormatterFactory;
17use MediaWiki\Message\Message;
18use MediaWiki\Parser\ParserFactory;
19use MediaWiki\SpecialPage\SpecialPage;
20use MediaWiki\Status\Status;
21use MediaWiki\Status\StatusFormatter;
22use MediaWiki\Title\Title;
23use MediaWiki\Title\TitleFormatter;
24use MessageGroup;
25use WikiPageMessageGroup;
26
27/**
28 * This special page allows exporting groups for offline translation.
29 *
30 * @author Niklas Laxström
31 * @author Siebrand Mazeland
32 * @license GPL-2.0-or-later
33 * @ingroup SpecialPage TranslateSpecialPage
34 */
35class ExportTranslationsSpecialPage extends SpecialPage {
36    /** Maximum size of a group until exporting is not allowed due to performance reasons. */
37    public const MAX_EXPORT_SIZE = 10000;
38    protected string $language;
39    protected string $format;
40    protected string $groupId;
41    private TitleFormatter $titleFormatter;
42    private ParserFactory $parserFactory;
43    private StatusFormatter $statusFormatter;
44    /** @var string[] */
45    private const VALID_FORMATS = [ 'export-as-po', 'export-to-file', 'export-as-csv' ];
46
47    public function __construct(
48        TitleFormatter $titleFormatter,
49        ParserFactory $parserFactory,
50        FormatterFactory $formatterFactory
51    ) {
52        parent::__construct( 'ExportTranslations' );
53        $this->titleFormatter = $titleFormatter;
54        $this->parserFactory = $parserFactory;
55        $this->statusFormatter = $formatterFactory->getStatusFormatter( $this );
56    }
57
58    /** @param null|string $par */
59    public function execute( $par ) {
60        $out = $this->getOutput();
61        $request = $this->getRequest();
62        $lang = $this->getLanguage();
63
64        $this->setHeaders();
65
66        $this->groupId = $request->getText( 'group', $par ?? '' );
67        $this->language = $request->getVal( 'language', $lang->getCode() );
68        $this->format = $request->getText( 'format' );
69
70        $this->outputForm();
71        $out->addModules( 'ext.translate.special.exporttranslations' );
72
73        if ( $this->groupId ) {
74            $status = $this->checkInput();
75            if ( !$status->isGood() ) {
76                $out->wrapWikiTextAsInterface(
77                    'error',
78                    $this->statusFormatter->getWikiText( $status )
79                );
80                return;
81            }
82
83            $status = $this->doExport();
84            if ( !$status->isGood() ) {
85                $out->addHTML(
86                    Html::errorBox( $this->statusFormatter->getHTML( $status, [ 'lang' => $lang ] ) )
87                );
88            }
89        }
90    }
91
92    private function outputForm(): void {
93        $fields = [
94            'group' => [
95                'type' => 'select',
96                'name' => 'group',
97                'id' => 'group',
98                'label-message' => 'translate-page-group',
99                'options' => $this->getGroupOptions(),
100                'default' => $this->groupId,
101            ],
102            'language' => [
103                // @todo Apply ULS to this field
104                'type' => 'select',
105                'name' => 'language',
106                'id' => 'language',
107                'label-message' => 'translate-page-language',
108                'options' => $this->getLanguageOptions(),
109                'default' => $this->language,
110            ],
111            'format' => [
112                'type' => 'radio',
113                'name' => 'format',
114                'id' => 'format',
115                'label-message' => 'translate-export-form-format',
116                'flatlist' => true,
117                'options' => $this->getFormatOptions(),
118                'default' => $this->format,
119            ],
120        ];
121        HTMLForm::factory( 'ooui', $fields, $this->getContext() )
122            ->setMethod( 'get' )
123            ->setId( 'mw-export-message-group-form' )
124            ->setWrapperLegendMsg( 'translate-page-settings-legend' )
125            ->setSubmitTextMsg( 'translate-submit' )
126            ->prepareForm()
127            ->displayForm( false );
128    }
129
130    private function getGroupOptions(): array {
131        $groups = MessageGroups::getAllGroups();
132        uasort( $groups, [ MessageGroups::class, 'groupLabelSort' ] );
133
134        $options = [];
135        foreach ( $groups as $id => $group ) {
136            if ( !$group->exists() ) {
137                continue;
138            }
139
140            $options[$group->getLabel()] = $id;
141        }
142
143        return $options;
144    }
145
146    /** @return string[] */
147    private function getLanguageOptions(): array {
148        $languages = Utilities::getLanguageNames( 'en' );
149        $options = [];
150        foreach ( $languages as $code => $name ) {
151            $options["$code - $name"] = $code;
152        }
153
154        return $options;
155    }
156
157    /** @return string[] */
158    private function getFormatOptions(): array {
159        $options = [];
160        foreach ( self::VALID_FORMATS as $format ) {
161            // translate-taskui-export-to-file, translate-taskui-export-as-po
162            $options[ $this->msg( "translate-taskui-$format" )->escaped() ] = $format;
163        }
164        return $options;
165    }
166
167    private function checkInput(): Status {
168        $status = Status::newGood();
169
170        $msgGroup = MessageGroups::getGroup( $this->groupId );
171        if ( $msgGroup === null ) {
172            $status->fatal( 'translate-page-no-such-group' );
173        } elseif ( MessageGroups::isDynamic( $msgGroup ) ) {
174            $status->fatal( 'translate-export-not-supported' );
175        }
176
177        $langNames = Utilities::getLanguageNames( 'en' );
178        if ( !isset( $langNames[$this->language] ) ) {
179            $status->fatal( 'translate-page-no-such-language' );
180        }
181
182        // Do not show this error if invalid format is specified for translatable page
183        // groups as we can show a textarea box containing the translation page text
184        // (however it's not currently supported for other groups).
185        if (
186            !$msgGroup instanceof WikiPageMessageGroup
187            && $this->format
188            && !in_array( $this->format, self::VALID_FORMATS )
189        ) {
190            $status->fatal( 'translate-export-invalid-format' );
191        }
192
193        if ( $this->format === 'export-to-file'
194            && !$msgGroup instanceof FileBasedMessageGroup
195        ) {
196            $status->fatal( 'translate-export-format-notsupported' );
197        }
198
199        if ( $msgGroup && !MessageGroups::isDynamic( $msgGroup ) ) {
200            $size = count( $msgGroup->getKeys() );
201            if ( $size > self::MAX_EXPORT_SIZE ) {
202                $status->fatal(
203                    'translate-export-group-too-large',
204                    Message::numParam( self::MAX_EXPORT_SIZE )
205                );
206            }
207        }
208
209        return $status;
210    }
211
212    private function doExport(): Status {
213        $out = $this->getOutput();
214        $group = MessageGroups::getGroup( $this->groupId );
215        $collection = $this->setupCollection( $group );
216
217        switch ( $this->format ) {
218            case 'export-as-po':
219                $out->disable();
220
221                $fileFormat = null;
222                if ( $group instanceof FileBasedMessageGroup ) {
223                    $fileFormat = $group->getFFS();
224                }
225
226                if ( !$fileFormat instanceof GettextFormat ) {
227                    if ( !$group instanceof FileBasedMessageGroup ) {
228                        $group = FileBasedMessageGroup::newFromMessageGroup( $group );
229                    }
230
231                    $fileFormat = new GettextFormat( $group );
232                }
233
234                $fileFormat->setOfflineMode( true );
235
236                $filename = "{$group->getId()}_{$this->language}.po";
237                $this->sendExportHeaders( $filename );
238
239                echo $fileFormat->writeIntoVariable( $collection );
240                break;
241
242            case 'export-to-file':
243                // This will never happen since its checked previously but add the check to keep
244                // phan and IDE happy. See checkInput method
245                if ( !$group instanceof FileBasedMessageGroup ) {
246                    throw new LogicException(
247                        "'export-to-file' requested for a non FileBasedMessageGroup {$group->getId()}"
248                    );
249                }
250
251                $messages = $group->getFFS()->writeIntoVariable( $collection );
252
253                if ( $messages === '' ) {
254                    return Status::newFatal( 'translate-export-format-file-empty' );
255                }
256
257                $out->disable();
258                $filename = basename( $group->getSourceFilePath( $collection->getLanguage() ) );
259                $this->sendExportHeaders( $filename );
260                echo $messages;
261                break;
262
263            case 'export-as-csv':
264                $out->disable();
265                $filename = "{$group->getId()}_{$this->language}.csv";
266                $this->sendExportHeaders( $filename );
267                $this->exportCSV( $collection, $group->getSourceLanguage() );
268                break;
269
270            default:
271                // @todo Add web viewing for groups other than WikiPageMessageGroup
272                if ( !$group instanceof WikiPageMessageGroup ) {
273                    return Status::newFatal( 'translate-export-format-notsupported' );
274                }
275
276                $translatablePage = TranslatablePage::newFromTitle( $group->getTitle() );
277                $translationPage = $translatablePage->getTranslationPage( $collection->getLanguage() );
278
279                $translationPage->filterMessageCollection( $collection );
280                $text = $translationPage->generateSourceFromMessageCollection(
281                    $this->parserFactory->getInstance(),
282                    $collection
283                );
284
285                $displayTitle = $translatablePage->getPageDisplayTitle( $this->language );
286                if ( $displayTitle ) {
287                    $text = "{{DISPLAYTITLE:$displayTitle}}$text";
288                }
289
290                $box = Html::element(
291                    'textarea',
292                    [ 'id' => 'wpTextbox', 'rows' => 40, ],
293                    $text
294                );
295                $out->addHTML( $box );
296
297        }
298
299        return Status::newGood();
300    }
301
302    private function setupCollection( MessageGroup $group ): MessageCollection {
303        $collection = $group->initCollection( $this->language );
304
305        // Don't export ignored, unless it is the source language or message documentation
306        $translateDocCode = $this->getConfig()->get( 'TranslateDocumentationLanguageCode' );
307        if ( $this->language !== $translateDocCode
308            && $this->language !== $group->getSourceLanguage()
309        ) {
310            $collection->filter( MessageCollection::FILTER_IGNORED, MessageCollection::EXCLUDE_MATCHING );
311        }
312
313        $collection->loadTranslations();
314
315        return $collection;
316    }
317
318    /** Send the appropriate response headers for the export */
319    private function sendExportHeaders( string $fileName ): void {
320        $response = $this->getRequest()->response();
321        $response->header( 'Content-Type: text/plain; charset=UTF-8' );
322        $response->header( "Content-Disposition: attachment; filename=\"$fileName\"" );
323    }
324
325    private function exportCSV( MessageCollection $collection, string $sourceLanguageCode ): void {
326        $fp = fopen( 'php://output', 'w' );
327        $exportingSourceLanguage = $sourceLanguageCode === $this->language;
328
329        $header = [
330            $this->msg( 'translate-export-csv-message-title' )->text(),
331            $this->msg( 'translate-export-csv-definition' )->text()
332        ];
333
334        if ( !$exportingSourceLanguage ) {
335            $header[] = $this->language;
336        }
337
338        fputcsv( $fp, $header );
339
340        foreach ( $collection->keys() as $messageKey => $titleValue ) {
341            $message = $collection[ $messageKey ];
342            $prefixedTitleText = $this->titleFormatter->getPrefixedText( $titleValue );
343
344            $handle = new MessageHandle( Title::newFromText( $prefixedTitleText ) );
345            $sourceLanguageTitle = $handle->getTitleForLanguage( $sourceLanguageCode );
346
347            $row = [ $sourceLanguageTitle->getPrefixedText(), $message->definition() ];
348
349            if ( !$exportingSourceLanguage ) {
350                $row[] = $message->translation();
351            }
352
353            fputcsv( $fp, $row );
354        }
355
356        fclose( $fp );
357    }
358
359    protected function getGroupName() {
360        return 'translation';
361    }
362}