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