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