Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 213
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiContentTranslationPublish
0.00% covered (danger)
0.00%
0 / 213
0.00% covered (danger)
0.00%
0 / 13
2070
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
 getParsoidClient
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 saveWikitext
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
12
 getTags
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getCategories
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 removeApiCategoryNamespacePrefix
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 publish
0.00% covered (danger)
0.00%
0 / 79
0.00% covered (danger)
0.00%
0 / 1
156
 notifyTranslator
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 30
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
1<?php
2/**
3 * Saving a wiki page created using ContentTranslation.
4 * The following special things happen when the page is created:
5 * - HTML from the translation editor's contenteditable is converted to wiki syntax using Parsoid.
6 * - A change tag is added. CX2 uses an additional tag.
7 * - The edit summary shows a link to the revision from which the translation was made.
8 * - Optionally, a template is added if the article appears to have a lot of machine translation.
9 * - Categories are hidden in <nowiki> if the page is published to the user namespace.
10 * - Information about the translated page is saved to the central ContentTranslation database.
11 * - When relevant, values of MediaWiki CAPTCHA can be sent.
12 * - When relevant, Echo notifications about publishing milestones will be sent.
13 * This borrows heavily from ApiVisualEditorEdit.
14 *
15 * @copyright See AUTHORS.txt
16 * @license GPL-2.0-or-later
17 */
18
19namespace ContentTranslation\ActionApi;
20
21use ContentTranslation\LogNames;
22use ContentTranslation\Notification;
23use ContentTranslation\ParsoidClient;
24use ContentTranslation\ParsoidClientFactory;
25use ContentTranslation\Service\TranslationTargetUrlCreator;
26use ContentTranslation\Service\UserPermissionChecker;
27use ContentTranslation\SiteMapper;
28use ContentTranslation\Store\TranslationStore;
29use ContentTranslation\Store\TranslatorStore;
30use ContentTranslation\Translation;
31use Exception;
32use MediaWiki\Api\ApiBase;
33use MediaWiki\Api\ApiMain;
34use MediaWiki\Api\ApiUsageException;
35use MediaWiki\ChangeTags\ChangeTagsStore;
36use MediaWiki\Deferred\DeferredUpdates;
37use MediaWiki\Language\LanguageFactory;
38use MediaWiki\Language\LanguageNameUtils;
39use MediaWiki\Logger\LoggerFactory;
40use MediaWiki\Registration\ExtensionRegistry;
41use MediaWiki\Request\DerivativeRequest;
42use MediaWiki\Title\Title;
43use Psr\Log\LoggerInterface;
44use Wikimedia\Deflate;
45use Wikimedia\ParamValidator\ParamValidator;
46use Wikimedia\ParamValidator\TypeDef\IntegerDef;
47use Wikimedia\Stats\StatsFactory;
48
49class ApiContentTranslationPublish extends ApiBase {
50    private ?Translation $translation;
51    private LoggerInterface $logger;
52
53    public function __construct(
54        ApiMain $main,
55        string $name,
56        private readonly ParsoidClientFactory $parsoidClientFactory,
57        private readonly LanguageFactory $languageFactory,
58        private readonly StatsFactory $statsFactory,
59        private readonly LanguageNameUtils $languageNameUtils,
60        private readonly TranslationStore $translationStore,
61        private readonly TranslationTargetUrlCreator $targetUrlCreator,
62        private readonly UserPermissionChecker $userPermissionChecker,
63        private readonly ChangeTagsStore $changeTagsStore,
64        private readonly TranslatorStore $translatorStore
65    ) {
66        parent::__construct( $main, $name );
67        $this->logger = LoggerFactory::getInstance( LogNames::MAIN );
68    }
69
70    protected function getParsoidClient(): ParsoidClient {
71        return $this->parsoidClientFactory->createParsoidClient();
72    }
73
74    /**
75     * @param Title $title
76     * @param string $wikitext
77     * @param array $params
78     * @return array
79     */
80    protected function saveWikitext( $title, $wikitext, $params ) {
81        $categories = $this->getCategories( $params );
82        if ( count( $categories ) ) {
83            $categoryText = "\n[[" . implode( "]]\n[[", $categories ) . ']]';
84            // If publishing to User namespace, wrap categories in <nowiki>
85            // to avoid blocks by abuse filter. See T88007.
86            if ( $title->inNamespace( NS_USER ) ) {
87                $categoryText = "\n<nowiki>$categoryText</nowiki>";
88            }
89            $wikitext .= $categoryText;
90        }
91
92        $sourceLink = '[[:' . Sitemapper::getDomainCode( $params['from'] )
93            . ':Special:Redirect/revision/'
94            . $this->translation->translation['sourceRevisionId']
95            . '|' . $params['sourcetitle'] . ']]';
96
97        $summary = $this->msg(
98            'cx-publish-summary',
99            $sourceLink
100        )->inContentLanguage()->text();
101
102        $apiParams = [
103            'action' => 'edit',
104            'title' => $title->getPrefixedDBkey(),
105            'text' => $wikitext,
106            'summary' => $summary,
107        ];
108
109        $request = $this->getRequest();
110
111        $api = new ApiMain(
112            new DerivativeRequest(
113                $request,
114                $apiParams + $request->getValues(),
115                true // was posted
116            ),
117            true // enable write
118        );
119
120        $api->execute();
121
122        return $api->getResult()->getResultData();
123    }
124
125    protected function getTags( array $params ): array {
126        $tags = $params['publishtags'];
127        $tags[] = 'contenttranslation';
128        if ( $params['cxversion'] === 2 ) {
129            $tags[] = 'contenttranslation-v2'; // Tag for CX2: contenttranslation-v2
130        }
131        // Remove any tags that are not registered.
132        return array_intersect(
133            $tags,
134            $this->changeTagsStore->listSoftwareActivatedTags()
135        );
136    }
137
138    protected function getCategories( array $params ): array {
139        $trackingCategoryMsg = 'cx-unreviewed-translation-category';
140        $categories = [];
141
142        if ( $params['categories'] ) {
143            $categories = explode( '|', $params['categories'] );
144        }
145
146        $trackingCategoryKey = array_search( $trackingCategoryMsg, $categories );
147        if ( $trackingCategoryKey !== false ) {
148            $cat = $this->msg( $trackingCategoryMsg )->inContentLanguage()->plain();
149            $containerCategory = Title::makeTitleSafe( NS_CATEGORY, $cat );
150            if ( $cat !== '-' && $containerCategory ) {
151                // Title without namespace prefix
152                $categories[$trackingCategoryKey] = $containerCategory->getText();
153                // Record using Prometheus that the published translation is marked for review
154                $this->statsFactory->getCounter( 'ContentTranslation_publish_highmt_total' )
155                    ->setLabel( 'langCode', $params['to'] )
156                    ->increment();
157            } else {
158                wfDebug( __METHOD__ . ": [[MediaWiki:$trackingCategoryMsg]] is not a valid title!\n" );
159                unset( $categories[$trackingCategoryKey] );
160            }
161        }
162
163        // Validate and normalize all categories.
164        foreach ( $categories as $index => $category ) {
165            $category = $this->removeApiCategoryNamespacePrefix( $category, $params['to'] );
166            // Also remove the namespace in English, if any. May be from T264490
167            $category = $this->removeApiCategoryNamespacePrefix( $category, 'en' );
168            $title = Title::makeTitleSafe( NS_CATEGORY, $category );
169            if ( $title !== null ) {
170                $categories[$index] = $title->getPrefixedText();
171            } else {
172                unset( $categories[$index] );
173            }
174        }
175
176        // Guard against duplicates, if any.
177        $categories = array_unique( $categories );
178
179        return $categories;
180    }
181
182    /**
183     * Removes category namespace prefix for a given category received
184     * from API, if existing, otherwise returns category as is
185     * @param string $category
186     * @param string $targetLanguage
187     * @return string
188     */
189    private function removeApiCategoryNamespacePrefix( $category, $targetLanguage ) {
190        $targetLanguage = $this->languageFactory->getLanguage( $targetLanguage );
191        $targetLanguageCategoryPrefix = $targetLanguage->getNsText( NS_CATEGORY ) . ":";
192        if ( substr( $category, 0, strlen( $targetLanguageCategoryPrefix ) ) === $targetLanguageCategoryPrefix ) {
193            return substr( $category, strlen( $targetLanguageCategoryPrefix ) );
194        }
195        return $category;
196    }
197
198    public function execute() {
199        $params = $this->extractRequestParams();
200
201        $block = $this->getUser()->getBlock();
202        if ( $block && $block->isSitewide() ) {
203            $this->dieBlocked( $block );
204        }
205
206        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['from'] ) ) {
207            $this->dieWithError( 'apierror-cx-invalidsourcelanguage', 'invalidsourcelanguage' );
208        }
209
210        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['to'] ) ) {
211            $this->dieWithError( 'apierror-cx-invalidtargetlanguage', 'invalidtargetlanguage' );
212        }
213
214        if ( trim( $params['html'] ) === '' ) {
215            $this->dieWithError( [ 'apierror-paramempty', 'html' ], 'invalidhtml' );
216        }
217
218        // Check user group publishing requirements
219        if ( !$this->userPermissionChecker->checkUserCanPublish( $this->getUser(), $params['title'] ) ) {
220            $this->dieWithError( 'apierror-cx-publish-usergroup-required', 'usergroup-required' );
221        }
222
223        $this->publish();
224    }
225
226    public function publish() {
227        $params = $this->extractRequestParams();
228        $user = $this->getUser();
229
230        $targetTitle = Title::newFromText( $params['title'] );
231        if ( !$targetTitle ) {
232            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] );
233        }
234
235        [ 'sourcetitle' => $sourceTitle, 'from' => $sourceLanguage, 'to' => $targetLanguage ] = $params;
236        $this->translation = $this->translationStore->findTranslationByUser(
237            $user,
238            $sourceTitle,
239            $sourceLanguage,
240            $targetLanguage
241        );
242
243        if ( $this->translation === null ) {
244            $this->dieWithError( 'apierror-cx-translationnotfound', 'translationnotfound' );
245        }
246
247        $html = Deflate::inflate( $params['html'] );
248        if ( !$html->isGood() ) {
249            $this->dieWithError( 'deflate-invaliddeflate', 'invaliddeflate' );
250        }
251        try {
252            $wikitext = $this->getParsoidClient()->convertHtmlToWikitext(
253                $targetTitle,
254                $html->getValue()
255            )['body'];
256        } catch ( Exception $e ) {
257            $this->logger->error(
258                'Error when converting content HTML to Wikitext using ParsoidClient for {targetTitle}, {errorMessage}',
259                [
260                    'errorMessage' => $e->getMessage(),
261                    'targetTitle' => $targetTitle->getPrefixedDBkey(),
262                ]
263            );
264            $this->dieWithError(
265                [ 'apierror-cx-docserverexception', wfEscapeWikiText( $e->getMessage() ) ], 'docserver'
266            );
267        }
268
269        try {
270            $saveResult = $this->saveWikitext( $targetTitle, $wikitext, $params );
271        } catch ( ApiUsageException $e ) {
272            throw $e;
273        } catch ( Exception $e ) {
274            $this->logger->error(
275                'Error when publishing content for {targetTitle}, {exception}',
276                [
277                    'targetTitle' => $targetTitle->getPrefixedDBkey(),
278                    'exception' => $e->getMessage(),
279                ]
280            );
281
282            throw $e;
283        }
284
285        $editStatus = $saveResult['edit']['result'];
286
287        if ( $editStatus === 'Success' ) {
288            if ( isset( $saveResult['edit']['newrevid'] ) ) {
289                $tags = $this->getTags( $params );
290                // Add the tags post-send, after RC row insertion
291                $revId = intval( $saveResult['edit']['newrevid'] );
292                DeferredUpdates::addCallableUpdate( function () use ( $revId, $tags ) {
293                    $this->changeTagsStore->addTags( $tags, null, $revId, null );
294                } );
295            }
296
297            $targetURL = $this->targetUrlCreator->createTargetUrl( $targetTitle->getPrefixedDBkey(), $params['to'] );
298            $result = [
299                'result' => 'success',
300                'targeturl' => $targetURL
301            ];
302
303            $this->translation->translation['status'] = TranslationStore::TRANSLATION_STATUS_PUBLISHED;
304            $this->translation->translation['targetURL'] = $targetURL;
305
306            if ( isset( $saveResult['edit']['newrevid'] ) ) {
307                $result['newrevid'] = intval( $saveResult['edit']['newrevid'] );
308                $this->translation->translation['targetRevisionId'] = $result['newrevid'];
309            }
310
311            if ( isset( $saveResult['edit']['pageid'] ) ) {
312                $result['pageid'] = intval( $saveResult['edit']['pageid'] );
313            }
314
315            // Save the translation history.
316            $this->translationStore->saveTranslation( $this->translation, $user );
317
318            // Notify user about milestones
319            $this->notifyTranslator();
320        } else {
321            $result = [
322                'result' => 'error',
323                'edit' => $saveResult['edit']
324            ];
325
326            // Don't bother logging captcha related errors
327            if ( !isset( $saveResult['edit']['captcha'] ) ) {
328                $this->logger->error(
329                    'Error when publishing content for {targetTitle}',
330                    [
331                        'targetTitle' => $targetTitle->getPrefixedDBkey(),
332                        'editResult' => json_encode( $saveResult['edit'], JSON_PRETTY_PRINT ),
333                    ]
334                );
335            }
336        }
337
338        $this->getResult()->addValue( null, $this->getModuleName(), $result );
339    }
340
341    /**
342     * Notify user about milestones.
343     */
344    public function notifyTranslator() {
345        $params = $this->extractRequestParams();
346
347        // Check if Echo is available. If not, skip.
348        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
349            return;
350        }
351
352        $user = $this->getUser();
353        $translationCount = $this->translatorStore->getTranslationCountForTranslator( $user );
354
355        switch ( $translationCount ) {
356            case 1:
357                Notification::firstTranslation( $user );
358                break;
359            case 2:
360                Notification::suggestionsAvailable( $user, $params['sourcetitle'] );
361                break;
362            case 10:
363                Notification::tenthTranslation( $user );
364                break;
365            case 100:
366                Notification::hundredthTranslation( $user );
367                break;
368        }
369    }
370
371    /** @inheritDoc */
372    public function getAllowedParams() {
373        return [
374            'title' => [
375                ParamValidator::PARAM_REQUIRED => true,
376            ],
377            'html' => [
378                ParamValidator::PARAM_REQUIRED => true,
379            ],
380            'from' => [
381                ParamValidator::PARAM_REQUIRED => true,
382            ],
383            'to' => [
384                ParamValidator::PARAM_REQUIRED => true,
385            ],
386            'sourcetitle' => [
387                ParamValidator::PARAM_REQUIRED => true,
388            ],
389            'categories' => null,
390            'publishtags' => [
391                ParamValidator::PARAM_ISMULTI => true,
392            ],
393            /** @todo These should be renamed to something all-lowercase and lacking a "wp" prefix */
394            'wpCaptchaId' => null,
395            'wpCaptchaWord' => null,
396            'cxversion' => [
397                ParamValidator::PARAM_TYPE => 'integer',
398                ParamValidator::PARAM_REQUIRED => true,
399                ApiBase::PARAM_RANGE_ENFORCE => true,
400                IntegerDef::PARAM_MIN => 1,
401                IntegerDef::PARAM_MAX => 2,
402            ],
403        ];
404    }
405
406    /** @inheritDoc */
407    public function needsToken() {
408        return 'csrf';
409    }
410
411    /** @inheritDoc */
412    public function isWriteMode() {
413        return true;
414    }
415
416    /** @inheritDoc */
417    public function isInternal() {
418        return true;
419    }
420}