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