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