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