Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 116
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
SimpleFormat
0.00% covered (danger)
0.00%
0 / 115
0.00% covered (danger)
0.00%
0 / 19
2550
0.00% covered (danger)
0.00%
0 / 1
 supportsFuzzy
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFileExtensions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setGroup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setWritePath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getWritePath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 exists
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 read
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 readFromVariable
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 write
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 writeIntoVariable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 writeReal
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 tryReadSource
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 tryReadFile
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 filterAuthors
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 isContentEqual
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shouldOverwrite
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isGroupFfsReadable
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\FileFormatSupport;
5
6use Exception;
7use FileBasedMessageGroup;
8use InvalidArgumentException;
9use LogicException;
10use MediaWiki\Extension\Translate\MessageLoading\Message;
11use MediaWiki\Extension\Translate\MessageLoading\MessageCollection;
12use MediaWiki\Extension\Translate\Services;
13use RuntimeException;
14use StringUtils;
15use UtfNormal\Validator;
16
17/**
18 * A very basic FileFormatSupport module that implements some basic functionality and
19 * a simple binary based file format. Other FFS classes can extend SimpleFormat and
20 * override suitable methods.
21 * @ingroup FileFormatSupport
22 * @author Niklas Laxström
23 */
24class SimpleFormat implements FileFormatSupport {
25
26    public function supportsFuzzy(): string {
27        return 'no';
28    }
29
30    public function getFileExtensions(): array {
31        return [];
32    }
33
34    protected FileBasedMessageGroup $group;
35    protected ?string $writePath = null;
36    /**
37     * Stores the FILES section of the YAML configuration,
38     * which can be accessed for extra FFS class specific options.
39     * @var mixed
40     */
41    protected $extra;
42
43    private const RECORD_SEPARATOR = "\0";
44    private const PART_SEPARATOR = "\0\0\0\0";
45
46    public function __construct( FileBasedMessageGroup $group ) {
47        $this->setGroup( $group );
48        $conf = $group->getConfiguration();
49        $this->extra = $conf['FILES'];
50    }
51
52    public function setGroup( FileBasedMessageGroup $group ): void {
53        $this->group = $group;
54    }
55
56    public function getGroup(): FileBasedMessageGroup {
57        return $this->group;
58    }
59
60    public function setWritePath( string $target ): void {
61        $this->writePath = $target;
62    }
63
64    public function getWritePath(): string {
65        return $this->writePath;
66    }
67
68    /**
69     * Returns true if the file for this message group in a given language
70     * exists. If no $code is given, the groups source language is assumed.
71     * NB: Some formats store all languages in the same file, and then this
72     * function will return true even if there are no translations to that
73     * language.
74     *
75     * @param string|bool $code
76     */
77    public function exists( $code = false ): bool {
78        if ( $code === false ) {
79            $code = $this->group->getSourceLanguage();
80        }
81
82        $filename = $this->group->getSourceFilePath( $code );
83        if ( $filename === null ) {
84            return false;
85        }
86
87        return file_exists( $filename );
88    }
89
90    /**
91     * Reads messages from the file in a given language and returns an array
92     * of AUTHORS, MESSAGES and possibly other properties.
93     *
94     * @return array|bool False if the file does not exist
95     * @throws RuntimeException if the file is not readable or has bad encoding
96     */
97    public function read( string $languageCode ) {
98        if ( !$this->isGroupFfsReadable() ) {
99            return [];
100        }
101
102        if ( !$this->exists( $languageCode ) ) {
103            return false;
104        }
105
106        $filename = $this->group->getSourceFilePath( $languageCode );
107        $input = file_get_contents( $filename );
108        if ( $input === false ) {
109            throw new RuntimeException( "Unable to read file $filename." );
110        }
111
112        if ( !StringUtils::isUtf8( $input ) ) {
113            throw new RuntimeException( "Contents of $filename are not valid utf-8." );
114        }
115
116        $input = Validator::cleanUp( $input );
117
118        // Strip BOM mark
119        $input = ltrim( $input, "\u{FEFF}" );
120
121        try {
122            return $this->readFromVariable( $input );
123        } catch ( Exception $e ) {
124            throw new RuntimeException( "Parsing $filename failed: " . $e->getMessage() );
125        }
126    }
127
128    /**
129     * Parse the message data given as a string in the SimpleFormat format
130     * and return it as an array of AUTHORS and MESSAGES.
131     *
132     * @throws InvalidArgumentException
133     */
134    public function readFromVariable( string $data ): array {
135        $parts = explode( self::PART_SEPARATOR, $data );
136
137        if ( count( $parts ) !== 2 ) {
138            throw new InvalidArgumentException( 'Wrong number of parts.' );
139        }
140
141        [ $authorsPart, $messagesPart ] = $parts;
142        $authors = explode( self::RECORD_SEPARATOR, $authorsPart );
143        $messages = [];
144
145        foreach ( explode( self::RECORD_SEPARATOR, $messagesPart ) as $line ) {
146            if ( $line === '' ) {
147                continue;
148            }
149
150            $lineParts = explode( '=', $line, 2 );
151
152            if ( count( $lineParts ) !== 2 ) {
153                throw new InvalidArgumentException( "Wrong number of parts in line $line." );
154            }
155
156            [ $key, $message ] = $lineParts;
157            $key = trim( $key );
158            $messages[$key] = $message;
159        }
160
161        $messages = $this->group->getMangler()->mangleArray( $messages );
162
163        return [
164            'AUTHORS' => $authors,
165            'MESSAGES' => $messages,
166        ];
167    }
168
169    /** Write the collection to file. */
170    public function write( MessageCollection $collection ): void {
171        $writePath = $this->writePath;
172
173        if ( $writePath === null ) {
174            throw new LogicException( 'Write path is not set. Set write path before calling write()' );
175        }
176
177        if ( !file_exists( $writePath ) ) {
178            throw new InvalidArgumentException( "Write path '$writePath' does not exist." );
179        }
180
181        if ( !is_writable( $writePath ) ) {
182            throw new InvalidArgumentException( "Write path '$writePath' is not writable." );
183        }
184
185        $targetFile = $writePath . '/' . $this->group->getTargetFilename( $collection->code );
186
187        $targetFileExists = file_exists( $targetFile );
188
189        if ( $targetFileExists ) {
190            $this->tryReadSource( $targetFile, $collection );
191        } else {
192            $sourceFile = $this->group->getSourceFilePath( $collection->code );
193            $this->tryReadSource( $sourceFile, $collection );
194        }
195
196        $output = $this->writeReal( $collection );
197        if ( !$output ) {
198            return;
199        }
200
201        // Some file formats might have changing parts, such as timestamp.
202        // This allows the file handler to skip updating files, where only
203        // the timestamp would change.
204        if ( $targetFileExists ) {
205            $oldContent = $this->tryReadFile( $targetFile );
206            if ( $oldContent === null || !$this->shouldOverwrite( $oldContent, $output ) ) {
207                return;
208            }
209        }
210
211        wfMkdirParents( dirname( $targetFile ), null, __METHOD__ );
212        file_put_contents( $targetFile, $output );
213    }
214
215    /** Read a collection and return it as a SimpleFormat formatted string. */
216    public function writeIntoVariable( MessageCollection $collection ): string {
217        $sourceFile = $this->group->getSourceFilePath( $collection->code );
218        $this->tryReadSource( $sourceFile, $collection );
219
220        return $this->writeReal( $collection );
221    }
222
223    protected function writeReal( MessageCollection $collection ): string {
224        $output = '';
225
226        $authors = $collection->getAuthors();
227        $authors = $this->filterAuthors( $authors, $collection->code );
228
229        $output .= implode( self::RECORD_SEPARATOR, $authors );
230        $output .= self::PART_SEPARATOR;
231
232        $mangler = $this->group->getMangler();
233
234        /** @var Message $m */
235        foreach ( $collection as $key => $m ) {
236            $key = $mangler->unmangle( $key );
237            $trans = $m->translation();
238            $output .= "$key=$trans" . self::RECORD_SEPARATOR;
239        }
240
241        return $output;
242    }
243
244    /**
245     * This tries to pick up external authors in the source files so that they
246     * are not lost if those authors are not among those who have translated in
247     * the wiki.
248     *
249     * @todo Get rid of this
250     */
251    protected function tryReadSource( string $filename, MessageCollection $collection ): void {
252        if ( !$this->isGroupFfsReadable() ) {
253            return;
254        }
255
256        $sourceText = $this->tryReadFile( $filename );
257
258        // No need to do anything in SimpleFormat if it's null,
259        // it only reads author data from it.
260        if ( $sourceText !== null ) {
261            $sourceData = $this->readFromVariable( $sourceText );
262
263            if ( isset( $sourceData['AUTHORS'] ) ) {
264                $collection->addCollectionAuthors( $sourceData['AUTHORS'] );
265            }
266        }
267    }
268
269    /**
270     * Read the contents of $filename and return it as a string.
271     * Return null if the file doesn't exist.
272     * Throw an exception if the file isn't readable
273     * or if the reading fails strangely.
274     * @throws InvalidArgumentException
275     */
276    protected function tryReadFile( string $filename ): ?string {
277        if ( $filename === '' || !file_exists( $filename ) ) {
278            return null;
279        }
280
281        if ( !is_readable( $filename ) ) {
282            throw new InvalidArgumentException( "File $filename is not readable." );
283        }
284
285        $data = file_get_contents( $filename );
286        if ( $data === false ) {
287            throw new InvalidArgumentException( "Unable to read file $filename." );
288        }
289
290        return $data;
291    }
292
293    /** Remove excluded authors. */
294    public function filterAuthors( array $authors, string $code ): array {
295        $groupId = $this->group->getId();
296        $configHelper = Services::getInstance()->getConfigHelper();
297        foreach ( $authors as $i => $v ) {
298            if ( $configHelper->isAuthorExcluded( $groupId, $code, (string)$v ) ) {
299                unset( $authors[$i] );
300            }
301        }
302
303        return array_values( $authors );
304    }
305
306    public function isContentEqual( ?string $a, ?string $b ): bool {
307        return $a === $b;
308    }
309
310    public function shouldOverwrite( string $a, string $b ): bool {
311        return true;
312    }
313
314    /**
315     * Check if the file format of the current group is readable by the file
316     * format system. This might happen if we are trying to export a JsonFormat
317     * or WikiPageMessage group to a GettextFormat.
318     */
319    public function isGroupFfsReadable(): bool {
320        try {
321            $ffs = $this->group->getFFS();
322        } catch ( RuntimeException $e ) {
323            if ( $e->getCode() === FileBasedMessageGroup::NO_FILE_FORMAT ) {
324                return false;
325            }
326
327            throw $e;
328        }
329
330        return get_class( $ffs ) === get_class( $this );
331    }
332}
333
334class_alias( SimpleFormat::class, 'SimpleFFS' );