Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 214
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 / 214
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 / 25
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 Deflate;
32use Exception;
33use MediaWiki\Api\ApiBase;
34use MediaWiki\Api\ApiMain;
35use MediaWiki\Api\ApiUsageException;
36use MediaWiki\ChangeTags\ChangeTagsStore;
37use MediaWiki\Deferred\DeferredUpdates;
38use MediaWiki\Languages\LanguageFactory;
39use MediaWiki\Languages\LanguageNameUtils;
40use MediaWiki\Logger\LoggerFactory;
41use MediaWiki\Registration\ExtensionRegistry;
42use MediaWiki\Request\DerivativeRequest;
43use MediaWiki\Title\Title;
44use Psr\Log\LoggerInterface;
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                    ->copyToStatsdAt( 'cx.publish.highmt.' . $params['to'] )
157                    ->increment();
158            } else {
159                wfDebug( __METHOD__ . ": [[MediaWiki:$trackingCategoryMsg]] is not a valid title!\n" );
160                unset( $categories[$trackingCategoryKey] );
161            }
162        }
163
164        // Validate and normalize all categories.
165        foreach ( $categories as $index => $category ) {
166            $category = $this->removeApiCategoryNamespacePrefix( $category, $params['to'] );
167            // Also remove the namespace in English, if any. May be from T264490
168            $category = $this->removeApiCategoryNamespacePrefix( $category, 'en' );
169            $title = Title::makeTitleSafe( NS_CATEGORY, $category );
170            if ( $title !== null ) {
171                $categories[$index] = $title->getPrefixedText();
172            } else {
173                unset( $categories[$index] );
174            }
175        }
176
177        // Guard against duplicates, if any.
178        $categories = array_unique( $categories );
179
180        return $categories;
181    }
182
183    /**
184     * Removes category namespace prefix for a given category received
185     * from API, if existing, otherwise returns category as is
186     * @param string $category
187     * @param string $targetLanguage
188     * @return string
189     */
190    private function removeApiCategoryNamespacePrefix( $category, $targetLanguage ) {
191        $targetLanguage = $this->languageFactory->getLanguage( $targetLanguage );
192        $targetLanguageCategoryPrefix = $targetLanguage->getNsText( NS_CATEGORY ) . ":";
193        if ( substr( $category, 0, strlen( $targetLanguageCategoryPrefix ) ) === $targetLanguageCategoryPrefix ) {
194            return substr( $category, strlen( $targetLanguageCategoryPrefix ) );
195        }
196        return $category;
197    }
198
199    public function execute() {
200        $params = $this->extractRequestParams();
201
202        $block = $this->getUser()->getBlock();
203        if ( $block && $block->isSitewide() ) {
204            $this->dieBlocked( $block );
205        }
206
207        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['from'] ) ) {
208            $this->dieWithError( 'apierror-cx-invalidsourcelanguage', 'invalidsourcelanguage' );
209        }
210
211        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['to'] ) ) {
212            $this->dieWithError( 'apierror-cx-invalidtargetlanguage', 'invalidtargetlanguage' );
213        }
214
215        if ( trim( $params['html'] ) === '' ) {
216            $this->dieWithError( [ 'apierror-paramempty', 'html' ], 'invalidhtml' );
217        }
218
219        // Check user group publishing requirements
220        if ( !$this->userPermissionChecker->checkUserCanPublish( $this->getUser(), $params['title'] ) ) {
221            $this->dieWithError( 'apierror-cx-publish-usergroup-required', 'usergroup-required' );
222        }
223
224        $this->publish();
225    }
226
227    public function publish() {
228        $params = $this->extractRequestParams();
229        $user = $this->getUser();
230
231        $targetTitle = Title::newFromText( $params['title'] );
232        if ( !$targetTitle ) {
233            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] );
234        }
235
236        [ 'sourcetitle' => $sourceTitle, 'from' => $sourceLanguage, 'to' => $targetLanguage ] = $params;
237        $this->translation = $this->translationStore->findTranslationByUser(
238            $user,
239            $sourceTitle,
240            $sourceLanguage,
241            $targetLanguage
242        );
243
244        if ( $this->translation === null ) {
245            $this->dieWithError( 'apierror-cx-translationnotfound', 'translationnotfound' );
246        }
247
248        $html = Deflate::inflate( $params['html'] );
249        if ( !$html->isGood() ) {
250            $this->dieWithError( 'deflate-invaliddeflate', 'invaliddeflate' );
251        }
252        try {
253            $wikitext = $this->getParsoidClient()->convertHtmlToWikitext(
254                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
255                $targetTitle,
256                $html->getValue()
257            )['body'];
258        } catch ( Exception $e ) {
259            $this->logger->error(
260                'Error when converting content HTML to Wikitext using ParsoidClient for {targetTitle}, {errorMessage}',
261                [
262                    'errorMessage' => $e->getMessage(),
263                    'targetTitle' => $targetTitle->getPrefixedDBkey(),
264                ]
265            );
266            $this->dieWithError(
267                [ 'apierror-cx-docserverexception', wfEscapeWikiText( $e->getMessage() ) ], 'docserver'
268            );
269        }
270
271        try {
272            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
273            $saveResult = $this->saveWikitext( $targetTitle, $wikitext, $params );
274        } catch ( ApiUsageException $e ) {
275            throw $e;
276        } catch ( Exception $e ) {
277            $this->logger->error(
278                'Error when publishing content for {targetTitle}, {exception}',
279                [
280                    'targetTitle' => $targetTitle->getPrefixedDBkey(),
281                    'exception' => $e->getMessage(),
282                ]
283            );
284
285            throw $e;
286        }
287
288        $editStatus = $saveResult['edit']['result'];
289
290        if ( $editStatus === 'Success' ) {
291            if ( isset( $saveResult['edit']['newrevid'] ) ) {
292                $tags = $this->getTags( $params );
293                // Add the tags post-send, after RC row insertion
294                $revId = intval( $saveResult['edit']['newrevid'] );
295                DeferredUpdates::addCallableUpdate( function () use ( $revId, $tags ) {
296                    $this->changeTagsStore->addTags( $tags, null, $revId, null );
297                } );
298            }
299
300            $targetURL = $this->targetUrlCreator->createTargetUrl( $targetTitle->getPrefixedDBkey(), $params['to'] );
301            $result = [
302                'result' => 'success',
303                'targeturl' => $targetURL
304            ];
305
306            $this->translation->translation['status'] = TranslationStore::TRANSLATION_STATUS_PUBLISHED;
307            $this->translation->translation['targetURL'] = $targetURL;
308
309            if ( isset( $saveResult['edit']['newrevid'] ) ) {
310                $result['newrevid'] = intval( $saveResult['edit']['newrevid'] );
311                $this->translation->translation['targetRevisionId'] = $result['newrevid'];
312            }
313
314            if ( isset( $saveResult['edit']['pageid'] ) ) {
315                $result['pageid'] = intval( $saveResult['edit']['pageid'] );
316            }
317
318            // Save the translation history.
319            $this->translationStore->saveTranslation( $this->translation, $user );
320
321            // Notify user about milestones
322            $this->notifyTranslator();
323        } else {
324            $result = [
325                'result' => 'error',
326                'edit' => $saveResult['edit']
327            ];
328
329            // Don't bother logging captcha related errors
330            if ( !isset( $saveResult['edit']['captcha'] ) ) {
331                $this->logger->error(
332                    'Error when publishing content for {targetTitle}',
333                    [
334                        'targetTitle' => $targetTitle->getPrefixedDBkey(),
335                        'editResult' => json_encode( $saveResult['edit'], JSON_PRETTY_PRINT ),
336                    ]
337                );
338            }
339        }
340
341        $this->getResult()->addValue( null, $this->getModuleName(), $result );
342    }
343
344    /**
345     * Notify user about milestones.
346     */
347    public function notifyTranslator() {
348        $params = $this->extractRequestParams();
349
350        // Check if Echo is available. If not, skip.
351        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
352            return;
353        }
354
355        $user = $this->getUser();
356        $translationCount = $this->translatorStore->getTranslationCountForTranslator( $user );
357
358        switch ( $translationCount ) {
359            case 1:
360                Notification::firstTranslation( $user );
361                break;
362            case 2:
363                Notification::suggestionsAvailable( $user, $params['sourcetitle'] );
364                break;
365            case 10:
366                Notification::tenthTranslation( $user );
367                break;
368            case 100:
369                Notification::hundredthTranslation( $user );
370                break;
371        }
372    }
373
374    /** @inheritDoc */
375    public function getAllowedParams() {
376        return [
377            'title' => [
378                ParamValidator::PARAM_REQUIRED => true,
379            ],
380            'html' => [
381                ParamValidator::PARAM_REQUIRED => true,
382            ],
383            'from' => [
384                ParamValidator::PARAM_REQUIRED => true,
385            ],
386            'to' => [
387                ParamValidator::PARAM_REQUIRED => true,
388            ],
389            'sourcetitle' => [
390                ParamValidator::PARAM_REQUIRED => true,
391            ],
392            'categories' => null,
393            'publishtags' => [
394                ParamValidator::PARAM_ISMULTI => true,
395            ],
396            /** @todo These should be renamed to something all-lowercase and lacking a "wp" prefix */
397            'wpCaptchaId' => null,
398            'wpCaptchaWord' => null,
399            'cxversion' => [
400                ParamValidator::PARAM_TYPE => 'integer',
401                ParamValidator::PARAM_REQUIRED => true,
402                ApiBase::PARAM_RANGE_ENFORCE => true,
403                IntegerDef::PARAM_MIN => 1,
404                IntegerDef::PARAM_MAX => 2,
405            ],
406        ];
407    }
408
409    /** @inheritDoc */
410    public function needsToken() {
411        return 'csrf';
412    }
413
414    /** @inheritDoc */
415    public function isWriteMode() {
416        return true;
417    }
418
419    /** @inheritDoc */
420    public function isInternal() {
421        return true;
422    }
423}