Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.20% covered (warning)
62.20%
79 / 127
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
CsvTranslationImporter
62.20% covered (warning)
62.20%
79 / 127
33.33% covered (danger)
33.33%
2 / 6
121.12
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseFile
90.00% covered (success)
90.00%
54 / 60
0.00% covered (danger)
0.00%
0 / 1
17.29
 importData
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
90
 getLanguagesFromHeader
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
6.12
 getMessageHandleIfValid
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 isCsvRowEmpty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\MessageGroupProcessing;
5
6use ContentHandler;
7use MediaWiki\CommentStore\CommentStoreComment;
8use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
9use MediaWiki\Extension\Translate\Utilities\Utilities;
10use MediaWiki\Page\WikiPageFactory;
11use MediaWiki\Permissions\Authority;
12use MediaWiki\Revision\SlotRecord;
13use MediaWiki\Title\Title;
14use SplFileObject;
15use Status;
16
17/**
18 * Parse, validate and import translations from a CSV file
19 * @since 2022.06
20 * @license GPL-2.0-or-later
21 * @author Abijeet Patro
22 */
23class CsvTranslationImporter {
24    /** @var WikiPageFactory */
25    private $wikiPageFactory;
26
27    public function __construct( WikiPageFactory $wikiPageFactory ) {
28        $this->wikiPageFactory = $wikiPageFactory;
29    }
30
31    /** Parse and validate the CSV file */
32    public function parseFile( string $csvFilePath ): Status {
33        if ( !file_exists( $csvFilePath ) || !is_file( $csvFilePath ) ) {
34            return Status::newFatal(
35                "CSV file path '$csvFilePath' does not exist, is not readable or is not a file"
36            );
37        }
38
39        $indexedLanguageCodes = [];
40        $currentRowCount = -1;
41        $importData = [];
42        $invalidRows = [
43            'emptyTitleRows' => [],
44            'invalidTitleRows' => [],
45            'groupNotFoundRows' => []
46        ];
47
48        $csvFileContent = new SplFileObject( $csvFilePath, 'r' );
49        while ( !$csvFileContent->eof() ) {
50            // Increment the row count at the beginning since we have a bunch of jump statements
51            // at various placaes
52            ++$currentRowCount;
53
54            $csvRow = $csvFileContent->fgetcsv();
55            if ( $this->isCsvRowEmpty( $csvRow ) ) {
56                continue;
57            }
58
59            if ( $currentRowCount === 0 ) {
60                // Validate the header
61                $status = $this->getLanguagesFromHeader( $csvRow );
62                if ( !$status->isGood() ) {
63                    return $status;
64                }
65                /** @var string[] $indexedLanguageCodes */
66                $indexedLanguageCodes = $status->getValue();
67                continue;
68            }
69
70            $rowData = [ 'translations' => [] ];
71            $messageTitle = isset( $csvRow[0] ) ? trim( $csvRow[0] ) : null;
72            if ( !$messageTitle ) {
73                $invalidRows['emptyTitleRows'][] = $currentRowCount + 1;
74                continue;
75            }
76
77            $handle = $this->getMessageHandleIfValid( $messageTitle );
78            if ( $handle === null ) {
79                $invalidRows['invalidTitleRows'][] = $currentRowCount + 1;
80                continue;
81            }
82
83            // Ensure that the group is present
84            $group = $handle->getGroup();
85            if ( !$group ) {
86                $invalidRows['groupNotFoundRows'][] = $currentRowCount + 1;
87                continue;
88            }
89
90            $sourceLanguage = $group->getSourceLanguage();
91
92            $rowData['messageTitle'] = $messageTitle;
93            foreach ( $indexedLanguageCodes as $languageCode => $index ) {
94                if ( $sourceLanguage === $languageCode ) {
95                    continue;
96                }
97
98                $rowData['translations'][$languageCode] = $csvRow[$index] ?? null;
99            }
100            $importData[] = $rowData;
101        }
102
103        $status = new Status();
104        if ( $invalidRows['emptyTitleRows'] ) {
105            $status->fatal(
106                'Empty message titles found on row(s): ' . implode( ',', $invalidRows['emptyTitleRows'] )
107            );
108        }
109
110        if ( $invalidRows['invalidTitleRows'] ) {
111            $status->fatal(
112                'Invalid message title(s) found on row(s): ' . implode( ',', $invalidRows['invalidTitleRows'] )
113            );
114        }
115
116        if ( $invalidRows['groupNotFoundRows'] ) {
117            $status->fatal(
118                'Group not found for message(s) on row(s) ' . implode( ',', $invalidRows['invalidTitleRows'] )
119            );
120        }
121
122        if ( !$status->isGood() ) {
123            return $status;
124        }
125
126        return Status::newGood( $importData );
127    }
128
129    /** Import the data returned from the parseFile method */
130    public function importData(
131        array $messagesWithTranslations,
132        Authority $authority,
133        string $comment,
134        ?callable $progressReporter = null
135    ): Status {
136        $commentStoreComment = CommentStoreComment::newUnsavedComment( $comment );
137
138        // Loop over each translation to import
139        $importStatus = new Status();
140        $failedStatuses = [];
141        $currentTranslation = 0;
142        foreach ( $messagesWithTranslations as $messageTranslation ) {
143            $messageTitleText = $messageTranslation['messageTitle'];
144            $messageTitle = Title::newFromText( $messageTitleText );
145            $messageHandle = new MessageHandle( $messageTitle );
146
147            $translationImportStatuses = [];
148
149            // Import each translation for the current message
150            $translations = $messageTranslation['translations'];
151            foreach ( $translations as $languageCode => $translation ) {
152                // Skip empty translations
153                if ( $translation === null || trim( $translation ) === '' ) {
154                    continue;
155                }
156
157                $translationTitle = $messageHandle->getTitleForLanguage( $languageCode );
158
159                // Perform the update for the translation page
160                $updater = $this->wikiPageFactory->newFromTitle( $translationTitle )
161                    ->newPageUpdater( $authority );
162                $content = ContentHandler::makeContent( $translation, $translationTitle );
163                $updater->setContent( SlotRecord::MAIN, $content );
164                $updater->setFlags( EDIT_FORCE_BOT );
165                $updater->saveRevision( $commentStoreComment );
166
167                $status = $updater->getStatus();
168                $translationImportStatuses[] = $status;
169                if ( !$status->isOK() ) {
170                    $failedStatuses[ $translationTitle->getPrefixedText() ] = $status;
171                }
172            }
173
174            ++$currentTranslation;
175            if ( $progressReporter ) {
176                call_user_func(
177                    $progressReporter,
178                    $messageTitle,
179                    $translationImportStatuses,
180                    count( $messagesWithTranslations ),
181                    $currentTranslation
182                );
183            }
184        }
185
186        if ( $failedStatuses ) {
187            foreach ( $failedStatuses as $failedStatus ) {
188                $importStatus->merge( $failedStatus );
189            }
190
191            $importStatus->setResult( false, $failedStatuses );
192        }
193
194        return $importStatus;
195    }
196
197    private function getLanguagesFromHeader( array $csvHeader ): Status {
198        if ( count( $csvHeader ) < 2 ) {
199            return Status::newFatal(
200                'CSV has < 2 columns. Assuming that there are no languages to import'
201            );
202        }
203
204        $languageCodesInHeader = array_slice( $csvHeader, 2 );
205        if ( $languageCodesInHeader === [] ) {
206            return Status::newFatal( 'No languages found for import' );
207        }
208
209        $invalidLanguageCodes = [];
210        $indexedLanguageCodes = [];
211        // First two columns are message title and definition
212        $originalLanguageIndex = 2;
213        foreach ( $languageCodesInHeader as $languageCode ) {
214            if ( !Utilities::isSupportedLanguageCode( strtolower( $languageCode ) ) ) {
215                $invalidLanguageCodes[] = $languageCode;
216            } else {
217                // Language codes maybe in upper case, convert to lower case for further use.
218                $indexedLanguageCodes[ strtolower( $languageCode ) ] = $originalLanguageIndex;
219            }
220            ++$originalLanguageIndex;
221        }
222
223        if ( $invalidLanguageCodes ) {
224            return Status::newFatal(
225                'Invalid language codes detected in CSV header: ' . implode( ', ', $invalidLanguageCodes )
226            );
227        }
228
229        return Status::newGood( $indexedLanguageCodes );
230    }
231
232    private function getMessageHandleIfValid( string $messageTitle ): ?MessageHandle {
233        $title = Title::newFromText( $messageTitle );
234        if ( $title === null ) {
235            return null;
236        }
237
238        $handle = new MessageHandle( $title );
239        if ( $handle->isValid() ) {
240            return $handle;
241        }
242
243        return null;
244    }
245
246    private function isCsvRowEmpty( array $csvRow ): bool {
247        return count( $csvRow ) === 1 && ( $csvRow[0] === null || trim( $csvRow[0] ) === '' );
248    }
249}