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 / 4 |
|
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 / 8 |
|
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 LogicException; |
8 | use MediaWiki\Extension\Translate\FileFormatSupport\GettextFormat; |
9 | use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups; |
10 | use MediaWiki\Extension\Translate\MessageLoading\MessageCollection; |
11 | use MediaWiki\Extension\Translate\MessageLoading\MessageHandle; |
12 | use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage; |
13 | use MediaWiki\Extension\Translate\Utilities\Utilities; |
14 | use MediaWiki\Html\Html; |
15 | use MediaWiki\HTMLForm\HTMLForm; |
16 | use MediaWiki\Language\FormatterFactory; |
17 | use MediaWiki\Message\Message; |
18 | use MediaWiki\Parser\ParserFactory; |
19 | use MediaWiki\SpecialPage\SpecialPage; |
20 | use MediaWiki\Status\Status; |
21 | use MediaWiki\Status\StatusFormatter; |
22 | use MediaWiki\Title\Title; |
23 | use MediaWiki\Title\TitleFormatter; |
24 | use MessageGroup; |
25 | use 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 | */ |
35 | class 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 | } |