Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 187 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
ExportTranslationsSpecialPage | |
0.00% |
0 / 187 |
|
0.00% |
0 / 12 |
2162 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
20 | |||
outputForm | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
2 | |||
getGroupOptions | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
getLanguageOptions | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getFormatOptions | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
checkInput | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
156 | |||
doExport | |
0.00% |
0 / 56 |
|
0.00% |
0 / 1 |
156 | |||
setupCollection | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
sendExportHeaders | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
exportCSV | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
20 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\Synchronization; |
5 | |
6 | use FileBasedMessageGroup; |
7 | use HTMLForm; |
8 | use LogicException; |
9 | use MediaWiki\Extension\Translate\FileFormatSupport\GettextFormat; |
10 | use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups; |
11 | use MediaWiki\Extension\Translate\MessageLoading\MessageCollection; |
12 | use MediaWiki\Extension\Translate\MessageLoading\MessageHandle; |
13 | use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage; |
14 | use MediaWiki\Extension\Translate\Utilities\Utilities; |
15 | use MediaWiki\Html\Html; |
16 | use MediaWiki\Title\Title; |
17 | use Message; |
18 | use MessageGroup; |
19 | use ParserFactory; |
20 | use SpecialPage; |
21 | use Status; |
22 | use TitleFormatter; |
23 | use 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 | */ |
33 | class 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 | } |