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