Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImportTranslationsSpecialPage
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 11
650
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
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 / 41
0.00% covered (danger)
0.00%
0 / 1
110
 checkError
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 outputForm
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
2
 loadFile
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 parseFile
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 setCachedData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getCachedData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 deleteCachedData
0.00% covered (danger)
0.00%
0 / 2
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 MediaWiki\Extension\Translate\FileFormatSupport\GettextFormat;
8use MediaWiki\Extension\Translate\FileFormatSupport\GettextParseException;
9use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
10use MediaWiki\Html\Html;
11use MediaWiki\SpecialPage\SpecialPage;
12use MediaWiki\Xml\Xml;
13use MessageGroupBase;
14use Wikimedia\ObjectCache\BagOStuff;
15
16/**
17 * Special page to import Gettext (.po) files exported using Translate extension.
18 * Does not support generic Gettext files.
19 *
20 * @author Niklas Laxström
21 * @author Siebrand Mazeland
22 * @license GPL-2.0-or-later
23 * @ingroup SpecialPage TranslateSpecialPage
24 */
25class ImportTranslationsSpecialPage extends SpecialPage {
26    private BagOStuff $cache;
27
28    public function __construct( BagOStuff $cache ) {
29        parent::__construct( 'ImportTranslations', 'translate-import' );
30        $this->cache = $cache;
31    }
32
33    public function doesWrites() {
34        return true;
35    }
36
37    protected function getGroupName() {
38        return 'translation';
39    }
40
41    /**
42     * Special page entry point.
43     * @param null|string $parameters
44     * @throws \PermissionsError
45     */
46    public function execute( $parameters ) {
47        $this->setHeaders();
48
49        // Security and validity checks
50        if ( !$this->userCanExecute( $this->getUser() ) ) {
51            $this->displayRestrictionError();
52        }
53
54        if ( !$this->getRequest()->wasPosted() ) {
55            $this->outputForm();
56
57            return;
58        }
59
60        $csrfTokenSet = $this->getContext()->getCsrfTokenSet();
61        if ( !$csrfTokenSet->matchTokenField( 'token' ) ) {
62            $this->getOutput()->addWikiMsg( 'session_fail_preview' );
63            $this->outputForm();
64
65            return;
66        }
67
68        if ( $this->getRequest()->getCheck( 'process' ) ) {
69            $data = $this->getCachedData();
70            if ( !$data ) {
71                $this->getOutput()->addWikiMsg( 'session_fail_preview' );
72                $this->outputForm();
73
74                return;
75            }
76        } else {
77            /**
78             * Proceed to loading and parsing if possible
79             * @todo: use a Status object instead?
80             */
81            $file = null;
82            $msg = $this->loadFile( $file );
83            if ( $this->checkError( $msg ) ) {
84                return;
85            }
86
87            $msg = $this->parseFile( $file );
88            if ( $this->checkError( $msg ) ) {
89                return;
90            }
91
92            $data = $msg[1];
93            $this->setCachedData( $data );
94        }
95
96        $messages = $data['MESSAGES'];
97        $groupId = $data['EXTRA']['METADATA']['group'];
98        $code = $data['EXTRA']['METADATA']['code'];
99
100        if ( !MessageGroups::exists( $groupId ) ) {
101            $errorWrap = "<div class='error'>\n$1\n</div>";
102            $this->getOutput()->wrapWikiMsg( $errorWrap, 'translate-import-err-stale-group' );
103
104            return;
105        }
106
107        $importer = new MessageWebImporter( $this->getPageTitle(), $this->getUser(), $this, $groupId, $code );
108        $allDone = $importer->execute( $messages );
109
110        $out = $this->getOutput();
111        $pageTitle = $this->getPageTitle();
112
113        if ( $allDone ) {
114            $this->deleteCachedData();
115            $this->outputForm();
116        }
117
118        $out->addBacklinkSubtitle( $pageTitle );
119    }
120
121    /**
122     * Checks for error state from the return value of loadFile and parseFile
123     * functions. Prints the error and the form and returns true if there is an
124     * error. Returns false and does nothing if there is no error.
125     */
126    private function checkError( array $msg ): bool {
127        // Give grep a chance to find the usages:
128        // translate-import-err-dl-failed, translate-import-err-ul-failed,
129        // translate-import-err-invalid-title, translate-import-err-no-such-file,
130        // translate-import-err-stale-group, translate-import-err-no-headers,
131        if ( $msg[0] !== 'ok' ) {
132            $errorWrap = "<div class='error'>\n$1\n</div>";
133            $msg[0] = 'translate-import-err-' . $msg[0];
134            $this->getOutput()->wrapWikiMsg( $errorWrap, $msg );
135            $this->outputForm();
136
137            return true;
138        }
139
140        return false;
141    }
142
143    /** Constructs and outputs file input form with supported methods. */
144    private function outputForm(): void {
145        $this->getOutput()->addModules( 'ext.translate.special.importtranslations' );
146        $this->getOutput()->addHelpLink( 'Help:Extension:Translate/Off-line_translation' );
147        /** Ugly but necessary form building ahead, ohoy */
148        $this->getOutput()->addHTML(
149            Xml::openElement( 'form', [
150                'action' => $this->getPageTitle()->getLocalURL(),
151                'method' => 'post',
152                'enctype' => 'multipart/form-data',
153                'id' => 'mw-translate-import',
154            ] ) .
155                Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() ) .
156                Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
157                Html::rawElement(
158                    'label',
159                    [],
160                    $this->msg( 'translate-import-from-local' )->escaped() .
161                    "\u{00A0}" .
162                    Html::input(
163                        'upload-local',
164                        $this->getRequest()->getText( 'upload-local' ),
165                        'file',
166                        [ 'id' => 'mw-translate-up-local-input' ]
167                    )
168                ) .
169                Html::submitButton( $this->msg( 'translate-import-load' )->text() ) .
170                Xml::closeElement( 'form' )
171        );
172    }
173
174    /** Try to get the file data from any of the supported methods. */
175    private function loadFile( ?string &$filedata ): array {
176        $filename = $this->getRequest()->getFileTempname( 'upload-local' );
177
178        if ( !is_uploaded_file( $filename ) ) {
179            return [ 'ul-failed' ];
180        }
181
182        $filedata = file_get_contents( $filename );
183
184        return [ 'ok' ];
185    }
186
187    /** Try parsing file. */
188    private function parseFile( string $data ): array {
189        /** Construct a dummy group for us...
190         * @todo Time to rethink the interface again?
191         * @var FileBasedMessageGroup $group
192         */
193        $group = MessageGroupBase::factory( [
194            'FILES' => [
195                'format' => 'Gettext',
196                'CtxtAsKey' => true,
197            ],
198            'BASIC' => [
199                'class' => FileBasedMessageGroup::class,
200                'namespace' => -1,
201            ]
202        ] );
203        '@phan-var FileBasedMessageGroup $group';
204
205        $ffs = new GettextFormat( $group );
206
207        try {
208            $parseOutput = $ffs->readFromVariable( $data );
209        } catch ( GettextParseException $e ) {
210            return [ 'no-headers' ];
211        }
212
213        // Special data added by GettextFormat
214        $metadata = $parseOutput['EXTRA']['METADATA'];
215
216        // This should catch everything that is not a Gettext file exported from us
217        if ( !isset( $metadata['code'] ) || !isset( $metadata['group'] ) ) {
218            return [ 'no-headers' ];
219        }
220
221        return [ 'ok', $parseOutput ];
222    }
223
224    private function setCachedData( $data ): void {
225        $key = $this->cache->makeKey( 'translate', 'webimport', $this->getUser()->getId() );
226        $this->cache->set( $key, $data, 60 * 30 );
227    }
228
229    private function getCachedData() {
230        $key = $this->cache->makeKey( 'translate', 'webimport', $this->getUser()->getId() );
231
232        return $this->cache->get( $key );
233    }
234
235    private function deleteCachedData(): bool {
236        $key = $this->cache->makeKey( 'translate', 'webimport', $this->getUser()->getId() );
237
238        return $this->cache->delete( $key );
239    }
240}