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