Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 156
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 / 156
0.00% covered (danger)
0.00%
0 / 10
870
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 52
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
12
 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\CategoriesStorageManager;
10use ContentTranslation\Exception\InvalidSectionDataException;
11use ContentTranslation\Exception\TranslationSaveException;
12use ContentTranslation\LogNames;
13use ContentTranslation\Manager\TranslationCorporaManager;
14use ContentTranslation\SiteMapper;
15use ContentTranslation\Store\TranslationStore;
16use ContentTranslation\Translation;
17use ContentTranslation\Translator;
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 Psr\Log\LoggerInterface;
27use Wikimedia\ParamValidator\ParamValidator;
28use Wikimedia\ParamValidator\TypeDef\IntegerDef;
29use Wikimedia\ParamValidator\TypeDef\StringDef;
30use Wikimedia\Rdbms\IConnectionProvider;
31
32class 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}