Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 191
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 / 191
0.00% covered (danger)
0.00%
0 / 13
1640
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
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 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 publish
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
72
 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\Notification;
22use ContentTranslation\ParsoidClient;
23use ContentTranslation\ParsoidClientFactory;
24use ContentTranslation\Service\TranslationTargetUrlCreator;
25use ContentTranslation\SiteMapper;
26use ContentTranslation\Store\TranslationStore;
27use ContentTranslation\Translation;
28use ContentTranslation\Translator;
29use Deflate;
30use Exception;
31use MediaWiki\Api\ApiBase;
32use MediaWiki\Api\ApiMain;
33use MediaWiki\ChangeTags\ChangeTagsStore;
34use MediaWiki\Deferred\DeferredUpdates;
35use MediaWiki\Languages\LanguageFactory;
36use MediaWiki\Languages\LanguageNameUtils;
37use MediaWiki\Registration\ExtensionRegistry;
38use MediaWiki\Request\DerivativeRequest;
39use MediaWiki\Title\Title;
40use Wikimedia\ParamValidator\ParamValidator;
41use Wikimedia\ParamValidator\TypeDef\IntegerDef;
42use Wikimedia\Stats\StatsFactory;
43
44class ApiContentTranslationPublish extends ApiBase {
45
46    protected ParsoidClientFactory $parsoidClientFactory;
47    protected ?Translation $translation;
48    private LanguageFactory $languageFactory;
49    private StatsFactory $statsFactory;
50    private LanguageNameUtils $languageNameUtils;
51    private TranslationStore $translationStore;
52    private TranslationTargetUrlCreator $targetUrlCreator;
53    private ChangeTagsStore $changeTagsStore;
54
55    public function __construct(
56        ApiMain $main,
57        string $name,
58        ParsoidClientFactory $parsoidClientFactory,
59        LanguageFactory $languageFactory,
60        StatsFactory $statsFactory,
61        LanguageNameUtils $languageNameUtils,
62        TranslationStore $translationStore,
63        TranslationTargetUrlCreator $targetUrlCreator,
64        ChangeTagsStore $changeTagsStore
65    ) {
66        parent::__construct( $main, $name );
67        $this->parsoidClientFactory = $parsoidClientFactory;
68        $this->languageFactory = $languageFactory;
69        $this->statsFactory = $statsFactory;
70        $this->languageNameUtils = $languageNameUtils;
71        $this->translationStore = $translationStore;
72        $this->targetUrlCreator = $targetUrlCreator;
73        $this->changeTagsStore = $changeTagsStore;
74    }
75
76    protected function getParsoidClient(): ParsoidClient {
77        return $this->parsoidClientFactory->createParsoidClient();
78    }
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 ) {
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 ) {
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        $this->publish();
220    }
221
222    public function publish() {
223        $params = $this->extractRequestParams();
224        $user = $this->getUser();
225
226        $targetTitle = Title::newFromText( $params['title'] );
227        if ( !$targetTitle ) {
228            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] );
229        }
230
231        [ 'sourcetitle' => $sourceTitle, 'from' => $sourceLanguage, 'to' => $targetLanguage ] = $params;
232        $this->translation = $this->translationStore->findTranslationByUser(
233            $user,
234            $sourceTitle,
235            $sourceLanguage,
236            $targetLanguage
237        );
238
239        if ( $this->translation === null ) {
240            $this->dieWithError( 'apierror-cx-translationnotfound', 'translationnotfound' );
241        }
242
243        $html = Deflate::inflate( $params['html'] );
244        if ( !$html->isGood() ) {
245            $this->dieWithError( 'deflate-invaliddeflate', 'invaliddeflate' );
246        }
247        try {
248            $wikitext = $this->getParsoidClient()->convertHtmlToWikitext(
249                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
250                $targetTitle,
251                $html->getValue()
252            )['body'];
253        } catch ( Exception $e ) {
254            $this->dieWithError(
255                [ 'apierror-cx-docserverexception', wfEscapeWikiText( $e->getMessage() ) ], 'docserver'
256            );
257        }
258
259        $saveresult = $this->saveWikitext( $targetTitle, $wikitext, $params );
260        $editStatus = $saveresult['edit']['result'];
261
262        if ( $editStatus === 'Success' ) {
263            if ( isset( $saveresult['edit']['newrevid'] ) ) {
264                $tags = $this->getTags( $params );
265                // Add the tags post-send, after RC row insertion
266                $revId = intval( $saveresult['edit']['newrevid'] );
267                DeferredUpdates::addCallableUpdate( function () use ( $revId, $tags ) {
268                    $this->changeTagsStore->addTags( $tags, null, $revId, null );
269                } );
270            }
271
272            $targetURL = $this->targetUrlCreator->createTargetUrl( $targetTitle->getPrefixedDBkey(), $params['to'] );
273            $result = [
274                'result' => 'success',
275                'targeturl' => $targetURL
276            ];
277
278            $this->translation->translation['status'] = TranslationStore::TRANSLATION_STATUS_PUBLISHED;
279            $this->translation->translation['targetURL'] = $targetURL;
280
281            if ( isset( $saveresult['edit']['newrevid'] ) ) {
282                $result['newrevid'] = intval( $saveresult['edit']['newrevid'] );
283                $this->translation->translation['targetRevisionId'] = $result['newrevid'];
284            }
285
286            // Save the translation history.
287            $this->translationStore->saveTranslation( $this->translation, $user );
288
289            // Notify user about milestones
290            $this->notifyTranslator();
291        } else {
292            $result = [
293                'result' => 'error',
294                'edit' => $saveresult['edit']
295            ];
296        }
297
298        $this->getResult()->addValue( null, $this->getModuleName(), $result );
299    }
300
301    /**
302     * Notify user about milestones.
303     */
304    public function notifyTranslator() {
305        $params = $this->extractRequestParams();
306
307        // Check if Echo is available. If not, skip.
308        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
309            return;
310        }
311
312        $user = $this->getUser();
313        $translator = new Translator( $user );
314        $translationCount = $translator->getTranslationsCount();
315
316        switch ( $translationCount ) {
317            case 1:
318                Notification::firstTranslation( $user );
319                break;
320            case 2:
321                Notification::suggestionsAvailable( $user, $params['sourcetitle'] );
322                break;
323            case 10:
324                Notification::tenthTranslation( $user );
325                break;
326            case 100:
327                Notification::hundredthTranslation( $user );
328                break;
329        }
330    }
331
332    public function getAllowedParams() {
333        return [
334            'title' => [
335                ParamValidator::PARAM_REQUIRED => true,
336            ],
337            'html' => [
338                ParamValidator::PARAM_REQUIRED => true,
339            ],
340            'from' => [
341                ParamValidator::PARAM_REQUIRED => true,
342            ],
343            'to' => [
344                ParamValidator::PARAM_REQUIRED => true,
345            ],
346            'sourcetitle' => [
347                ParamValidator::PARAM_REQUIRED => true,
348            ],
349            'categories' => null,
350            'publishtags' => [
351                ParamValidator::PARAM_ISMULTI => true,
352            ],
353            /** @todo These should be renamed to something all-lowercase and lacking a "wp" prefix */
354            'wpCaptchaId' => null,
355            'wpCaptchaWord' => null,
356            'cxversion' => [
357                ParamValidator::PARAM_TYPE => 'integer',
358                ParamValidator::PARAM_REQUIRED => true,
359                ApiBase::PARAM_RANGE_ENFORCE => true,
360                IntegerDef::PARAM_MIN => 1,
361                IntegerDef::PARAM_MAX => 2,
362            ],
363        ];
364    }
365
366    public function needsToken() {
367        return 'csrf';
368    }
369
370    public function isWriteMode() {
371        return true;
372    }
373
374    public function isInternal() {
375        return true;
376    }
377}