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