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 ContentHandler; |
7 | use MediaWiki\CommentStore\CommentStoreComment; |
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\Title\Title; |
14 | use SplFileObject; |
15 | use 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 | */ |
23 | class 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 | } |