Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 156 |
|
0.00% |
0 / 10 |
CRAP | |
0.00% |
0 / 1 |
ApiContentTranslationSave | |
0.00% |
0 / 156 |
|
0.00% |
0 / 10 |
870 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 52 |
|
0.00% |
0 / 1 |
210 | |||
saveTranslation | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
12 | |||
isValidCategoriesJSON | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
saveCategories | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
getAllowedParams | |
0.00% |
0 / 41 |
|
0.00% |
0 / 1 |
2 | |||
needsToken | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isWriteMode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isInternal | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getLogParams | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * @copyright See AUTHORS.txt |
4 | * @license GPL-2.0-or-later |
5 | */ |
6 | |
7 | namespace ContentTranslation\ActionApi; |
8 | |
9 | use ContentTranslation\CategoriesStorageManager; |
10 | use ContentTranslation\Exception\InvalidSectionDataException; |
11 | use ContentTranslation\Exception\TranslationSaveException; |
12 | use ContentTranslation\LogNames; |
13 | use ContentTranslation\Manager\TranslationCorporaManager; |
14 | use ContentTranslation\SiteMapper; |
15 | use ContentTranslation\Store\TranslationStore; |
16 | use ContentTranslation\Translation; |
17 | use ContentTranslation\Translator; |
18 | use ContentTranslation\Validator\TranslationUnitValidator; |
19 | use Deflate; |
20 | use MediaWiki\Api\ApiBase; |
21 | use MediaWiki\Api\ApiMain; |
22 | use MediaWiki\Json\FormatJson; |
23 | use MediaWiki\Languages\LanguageNameUtils; |
24 | use MediaWiki\Logger\LoggerFactory; |
25 | use MediaWiki\User\User; |
26 | use Psr\Log\LoggerInterface; |
27 | use Wikimedia\ParamValidator\ParamValidator; |
28 | use Wikimedia\ParamValidator\TypeDef\IntegerDef; |
29 | use Wikimedia\ParamValidator\TypeDef\StringDef; |
30 | use Wikimedia\Rdbms\IConnectionProvider; |
31 | |
32 | class ApiContentTranslationSave extends ApiBase { |
33 | private TranslationCorporaManager $corporaManager; |
34 | private IConnectionProvider $connectionProvider; |
35 | private TranslationUnitValidator $translationUnitValidator; |
36 | private LanguageNameUtils $languageNameUtils; |
37 | private TranslationStore $translationStore; |
38 | private LoggerInterface $logger; |
39 | /** |
40 | * 64KB |
41 | */ |
42 | private const SQL_BLOB_MAX_SIZE = 65535; |
43 | |
44 | public function __construct( |
45 | ApiMain $mainModule, |
46 | string $action, |
47 | TranslationCorporaManager $corporaManager, |
48 | IConnectionProvider $connectionProvider, |
49 | TranslationUnitValidator $translationUnitValidator, |
50 | LanguageNameUtils $languageNameUtils, |
51 | TranslationStore $translationStore |
52 | ) { |
53 | parent::__construct( $mainModule, $action ); |
54 | $this->corporaManager = $corporaManager; |
55 | $this->connectionProvider = $connectionProvider; |
56 | $this->translationUnitValidator = $translationUnitValidator; |
57 | $this->languageNameUtils = $languageNameUtils; |
58 | $this->translationStore = $translationStore; |
59 | $this->logger = LoggerFactory::getInstance( LogNames::MAIN ); |
60 | } |
61 | |
62 | public function execute() { |
63 | $params = $this->extractRequestParams(); |
64 | |
65 | if ( $this->connectionProvider->getPrimaryDatabase()->isReadOnly() ) { |
66 | $this->dieReadOnly(); |
67 | } |
68 | |
69 | $user = $this->getUser(); |
70 | $block = $user->getBlock(); |
71 | if ( $block && $block->isSitewide() ) { |
72 | $this->dieBlocked( $block ); |
73 | } |
74 | |
75 | if ( !$this->languageNameUtils->isKnownLanguageTag( $params['from'] ) ) { |
76 | $this->dieWithError( 'apierror-cx-invalidsourcelanguage', 'invalidsourcelanguage' ); |
77 | } |
78 | |
79 | if ( !$this->languageNameUtils->isKnownLanguageTag( $params['to'] ) ) { |
80 | $this->dieWithError( 'apierror-cx-invalidtargetlanguage', 'invalidtargetlanguage' ); |
81 | } |
82 | |
83 | $progress = FormatJson::decode( $params['progress'], true ); |
84 | if ( !is_array( $progress ) ) { |
85 | $this->dieWithError( 'apierror-cx-invalidprogress', 'invalidprogress' ); |
86 | } |
87 | |
88 | if ( $user->pingLimiter( 'cxsave' ) ) { |
89 | $this->dieWithError( 'apierror-ratelimited' ); |
90 | } |
91 | |
92 | $sourceCategories = $params['sourcecategories']; |
93 | $targetCategories = $params['targetcategories']; |
94 | if ( !$this->isValidCategoriesJSON( $sourceCategories ) ) { |
95 | $this->dieWithError( 'apierror-cx-invalidsourcecategories', 'invalidsourcecategories' ); |
96 | } |
97 | if ( !$this->isValidCategoriesJSON( $targetCategories ) ) { |
98 | $this->dieWithError( 'apierror-cx-invalidtargetcategories', 'invalidtargetcategories' ); |
99 | } |
100 | |
101 | $translator = new Translator( $user ); |
102 | try { |
103 | $translation = $this->saveTranslation( $params, $translator ); |
104 | } catch ( TranslationSaveException $e ) { |
105 | $this->logger->info( |
106 | 'Error saving translation: {sourcelanguage} -> {targetlanguage}, ' . |
107 | '{sourcetitle} -> {targettitle} by {user}: {exception}', |
108 | $this->getLogParams( $params, $user ) + [ 'exception' => $e->getMessage() ] |
109 | ); |
110 | $this->dieWithException( $e ); |
111 | } |
112 | |
113 | $translationId = $translation->getTranslationId(); |
114 | |
115 | $content = Deflate::inflate( $params['content'] ); |
116 | if ( !$content->isGood() ) { |
117 | $this->dieWithError( 'deflate-invaliddeflate', 'invaliddeflate' ); |
118 | } |
119 | |
120 | if ( trim( $content->getValue() ) === '' ) { |
121 | $this->dieWithError( [ 'apierror-paramempty', 'content' ], 'invalidcontent' ); |
122 | } |
123 | |
124 | try { |
125 | $translationUnits = $this->corporaManager->saveTranslationUnits( $translation, $content->getValue() ); |
126 | } catch ( InvalidSectionDataException $exception ) { |
127 | $this->dieWithError( 'apierror-cx-invalidsectiondata', 'invalidcontent' ); |
128 | } |
129 | |
130 | $validationResults = $this->translationUnitValidator->validateTranslationUnitsForTitleUser( |
131 | $translationUnits, |
132 | $translation->getData()['targetTitle'], |
133 | $this->getUser() |
134 | ); |
135 | |
136 | $this->saveCategories( $sourceCategories, $targetCategories, $translation ); |
137 | |
138 | $result = [ |
139 | 'result' => 'success', |
140 | 'validations' => $validationResults, |
141 | 'translationid' => $translationId |
142 | ]; |
143 | $this->getResult()->addValue( null, $this->getModuleName(), $result ); |
144 | } |
145 | |
146 | /** |
147 | * @throws TranslationSaveException |
148 | */ |
149 | private function saveTranslation( array $params, Translator $translator ): Translation { |
150 | [ 'sourcetitle' => $sourceTitle, 'from' => $sourceLanguage, 'to' => $targetLanguage ] = $params; |
151 | $existingTranslation = $this->translationStore->findTranslationByUser( |
152 | $translator->getUser(), |
153 | $sourceTitle, |
154 | $sourceLanguage, |
155 | $targetLanguage |
156 | ); |
157 | |
158 | if ( $existingTranslation ) { |
159 | $data = $existingTranslation->getData(); |
160 | } else { |
161 | $conflictingTranslations = $this->translationStore->findConflictingDraftTranslations( |
162 | $sourceTitle, |
163 | $sourceLanguage, |
164 | $targetLanguage |
165 | ); |
166 | |
167 | if ( $conflictingTranslations !== [] ) { |
168 | $this->dieWithError( 'apierror-cx-inuse', 'noaccess' ); |
169 | } |
170 | |
171 | // First time save, add relevant fields |
172 | $data = [ |
173 | 'sourceTitle' => $sourceTitle, |
174 | 'sourceLanguage' => $sourceLanguage, |
175 | 'targetLanguage' => $targetLanguage, |
176 | 'sourceURL' => SiteMapper::getPageURL( $sourceLanguage, $sourceTitle ), |
177 | ]; |
178 | } |
179 | |
180 | // Update updateable fields |
181 | $data['targetTitle'] = $params['title']; |
182 | $data['sourceRevisionId'] = $params['sourcerevision']; |
183 | $data['status'] = TranslationStore::TRANSLATION_STATUS_DRAFT; |
184 | $data['progress'] = $params['progress']; |
185 | $data['cxVersion'] = $params['cxversion'] ?? $this->getConfig()->get( 'ContentTranslationVersion' ); |
186 | |
187 | // Save the translation |
188 | $translation = new Translation( $data ); |
189 | $this->translationStore->saveTranslation( $translation, $translator->getUser() ); |
190 | |
191 | // Associate the translation with the translator |
192 | $translationId = $translation->getTranslationId(); |
193 | $translator->addTranslation( $translationId ); |
194 | |
195 | return $translation; |
196 | } |
197 | |
198 | /** |
199 | * Validate categories JSON param. |
200 | * |
201 | * @param string $categories JSON encoded array of categories |
202 | * @return bool |
203 | */ |
204 | protected function isValidCategoriesJSON( $categories ) { |
205 | // Categories are optional, so empty categories param is valid. |
206 | if ( $categories === null || $categories === '' ) { |
207 | return true; |
208 | } |
209 | |
210 | $parsedCategories = FormatJson::parse( $categories ); |
211 | return $parsedCategories->isGood(); |
212 | } |
213 | |
214 | /** |
215 | * Save categories in cx_corpora table, if any are supplied. |
216 | * |
217 | * @param string $sourceCategories JSON encoded array of source categories |
218 | * @param string $targetCategories JSON encoded array of target categories |
219 | * @param Translation $translation Recently saved parent translation object |
220 | */ |
221 | protected function saveCategories( |
222 | $sourceCategories, $targetCategories, Translation $translation |
223 | ) { |
224 | if ( !$sourceCategories && !$targetCategories ) { |
225 | return; |
226 | } |
227 | |
228 | $translationId = $translation->getTranslationId(); |
229 | $newTranslation = $translation->isNew(); |
230 | |
231 | CategoriesStorageManager::save( |
232 | $translationId, |
233 | $sourceCategories, |
234 | $targetCategories, |
235 | $newTranslation |
236 | ); |
237 | } |
238 | |
239 | /** @inheritDoc */ |
240 | public function getAllowedParams() { |
241 | return [ |
242 | 'from' => [ |
243 | ParamValidator::PARAM_REQUIRED => true, |
244 | ], |
245 | 'to' => [ |
246 | ParamValidator::PARAM_REQUIRED => true, |
247 | ], |
248 | 'sourcetitle' => [ |
249 | ParamValidator::PARAM_REQUIRED => true, |
250 | ], |
251 | 'title' => [ |
252 | ParamValidator::PARAM_REQUIRED => true, |
253 | ], |
254 | 'content' => [ |
255 | ParamValidator::PARAM_REQUIRED => true, |
256 | ], |
257 | 'sourcerevision' => [ |
258 | ParamValidator::PARAM_TYPE => 'integer', |
259 | ParamValidator::PARAM_REQUIRED => true, |
260 | ], |
261 | 'progress' => [ |
262 | ParamValidator::PARAM_REQUIRED => true, |
263 | ], |
264 | 'cxversion' => [ |
265 | ParamValidator::PARAM_TYPE => 'integer', |
266 | // Making this required immediately would cause issues for ongoing translations |
267 | // during deployment. Maybe this doesn't ever need to be required. |
268 | ParamValidator::PARAM_REQUIRED => false, |
269 | ApiBase::PARAM_RANGE_ENFORCE => true, |
270 | IntegerDef::PARAM_MIN => 1, |
271 | IntegerDef::PARAM_MAX => 2, |
272 | ], |
273 | 'sourcecategories' => [ |
274 | ParamValidator::PARAM_TYPE => 'string', |
275 | // We don't always save categories when saving translation. Only save |
276 | // categories when user changes target categories by reordering, |
277 | // removing or adding. Source categories are saved only once per |
278 | // session and target categories are saved for every change. |
279 | ParamValidator::PARAM_REQUIRED => false, |
280 | // Source and target categories are saved in cx_corpora table, whose |
281 | // content column is MEDIUMBLOB, which has 16MB limit, but we limit |
282 | // the size of categories at BLOB limit, which is 64KB. |
283 | StringDef::PARAM_MAX_BYTES => self::SQL_BLOB_MAX_SIZE |
284 | ], |
285 | 'targetcategories' => [ |
286 | ParamValidator::PARAM_TYPE => 'string', |
287 | ParamValidator::PARAM_REQUIRED => false, |
288 | StringDef::PARAM_MAX_BYTES => self::SQL_BLOB_MAX_SIZE |
289 | ] |
290 | ]; |
291 | } |
292 | |
293 | /** @inheritDoc */ |
294 | public function needsToken() { |
295 | return 'csrf'; |
296 | } |
297 | |
298 | /** @inheritDoc */ |
299 | public function isWriteMode() { |
300 | return true; |
301 | } |
302 | |
303 | /** @inheritDoc */ |
304 | public function isInternal() { |
305 | return true; |
306 | } |
307 | |
308 | private function getLogParams( array $params, User $user ): array { |
309 | return [ |
310 | 'sourcelanguage' => $params['from'], |
311 | 'targetlanguage' => $params['to'], |
312 | 'sourcetitle' => $params['sourcetitle'], |
313 | 'targettitle' => $params['title'], |
314 | 'user' => $user->getId() |
315 | ]; |
316 | } |
317 | } |