Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 624 |
|
0.00% |
0 / 19 |
CRAP | |
0.00% |
0 / 1 |
PageTranslationSpecialPage | |
0.00% |
0 / 624 |
|
0.00% |
0 / 19 |
8742 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 91 |
|
0.00% |
0 / 1 |
420 | |||
onActionMark | |
0.00% |
0 / 54 |
|
0.00% |
0 / 1 |
72 | |||
showSuccess | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
20 | |||
showGenericConfirmation | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
6 | |||
showUnlinkConfirmation | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
2 | |||
loadPagesFromDB | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
2 | |||
buildPageArray | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
classifyPages | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
20 | |||
listPages | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
56 | |||
actionLinks | |
0.00% |
0 / 64 |
|
0.00% |
0 / 1 |
72 | |||
showPage | |
0.00% |
0 / 145 |
|
0.00% |
0 / 1 |
306 | |||
priorityLanguagesForm | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
12 | |||
syntaxVersionForm | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
12 | |||
templateTransclusionForm | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
getPriorityLanguage | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getPageList | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
56 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\PageTranslation; |
5 | |
6 | use ContentHandler; |
7 | use DifferenceEngine; |
8 | use IDBAccessObject; |
9 | use JobQueueGroup; |
10 | use ManualLogEntry; |
11 | use MediaWiki\Cache\LinkBatchFactory; |
12 | use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups; |
13 | use MediaWiki\Extension\Translate\MessageGroupProcessing\RevTagStore; |
14 | use MediaWiki\Extension\Translate\MessageProcessing\MessageGroupMetadata; |
15 | use MediaWiki\Extension\Translate\Statistics\RebuildMessageGroupStatsJob; |
16 | use MediaWiki\Extension\Translate\Synchronization\MessageWebImporter; |
17 | use MediaWiki\Extension\Translate\Utilities\LanguagesMultiselectWidget; |
18 | use MediaWiki\Extension\Translate\Utilities\Utilities; |
19 | use MediaWiki\Extension\TranslationNotifications\SpecialNotifyTranslators; |
20 | use MediaWiki\Html\Html; |
21 | use MediaWiki\Languages\LanguageFactory; |
22 | use MediaWiki\MediaWikiServices; |
23 | use MediaWiki\Title\Title; |
24 | use OOUI\ButtonInputWidget; |
25 | use OOUI\CheckboxInputWidget; |
26 | use OOUI\FieldLayout; |
27 | use OOUI\FieldsetLayout; |
28 | use OOUI\HtmlSnippet; |
29 | use OOUI\TextInputWidget; |
30 | use PermissionsError; |
31 | use SpecialPage; |
32 | use UnexpectedValueException; |
33 | use UserBlockedError; |
34 | use WebRequest; |
35 | use Wikimedia\Rdbms\IResultWrapper; |
36 | use Xml; |
37 | use function count; |
38 | use function wfEscapeWikiText; |
39 | |
40 | /** |
41 | * A special page for marking revisions of pages for translation. |
42 | * |
43 | * This page is the main tool for translation administrators in the wiki. |
44 | * It will list all pages in their various states and provides actions |
45 | * that are suitable for given translatable page. |
46 | * |
47 | * @author Niklas Laxström |
48 | * @author Siebrand Mazeland |
49 | * @license GPL-2.0-or-later |
50 | */ |
51 | class PageTranslationSpecialPage extends SpecialPage { |
52 | private const DISPLAY_STATUS_MAPPING = [ |
53 | TranslatablePageStatus::PROPOSED => 'proposed', |
54 | TranslatablePageStatus::ACTIVE => 'active', |
55 | TranslatablePageStatus::OUTDATED => 'outdated', |
56 | TranslatablePageStatus::BROKEN => 'broken' |
57 | ]; |
58 | private LanguageFactory $languageFactory; |
59 | private LinkBatchFactory $linkBatchFactory; |
60 | private JobQueueGroup $jobQueueGroup; |
61 | private TranslatablePageMarker $translatablePageMarker; |
62 | private TranslatablePageParser $translatablePageParser; |
63 | private MessageGroupMetadata $messageGroupMetadata; |
64 | |
65 | public function __construct( |
66 | LanguageFactory $languageFactory, |
67 | LinkBatchFactory $linkBatchFactory, |
68 | JobQueueGroup $jobQueueGroup, |
69 | TranslatablePageMarker $translatablePageMarker, |
70 | TranslatablePageParser $translatablePageParser, |
71 | MessageGroupMetadata $messageGroupMetadata |
72 | ) { |
73 | parent::__construct( 'PageTranslation' ); |
74 | $this->languageFactory = $languageFactory; |
75 | $this->linkBatchFactory = $linkBatchFactory; |
76 | $this->jobQueueGroup = $jobQueueGroup; |
77 | $this->translatablePageMarker = $translatablePageMarker; |
78 | $this->translatablePageParser = $translatablePageParser; |
79 | $this->messageGroupMetadata = $messageGroupMetadata; |
80 | } |
81 | |
82 | public function doesWrites(): bool { |
83 | return true; |
84 | } |
85 | |
86 | protected function getGroupName(): string { |
87 | return 'translation'; |
88 | } |
89 | |
90 | public function execute( $parameters ) { |
91 | $this->setHeaders(); |
92 | |
93 | $user = $this->getUser(); |
94 | $request = $this->getRequest(); |
95 | |
96 | $target = $request->getText( 'target', $parameters ?? '' ); |
97 | $revision = $request->getIntOrNull( 'revision' ); |
98 | $action = $request->getVal( 'do' ); |
99 | $out = $this->getOutput(); |
100 | $out->addModules( 'ext.translate.special.pagetranslation' ); |
101 | $out->addModuleStyles( 'ext.translate.specialpages.styles' ); |
102 | $out->addHelpLink( 'Help:Extension:Translate/Page_translation_example' ); |
103 | $out->enableOOUI(); |
104 | |
105 | if ( $target === '' ) { |
106 | $this->listPages(); |
107 | |
108 | return; |
109 | } |
110 | |
111 | // Anything else than listing the pages need permissions |
112 | if ( !$user->isAllowed( 'pagetranslation' ) ) { |
113 | throw new PermissionsError( 'pagetranslation' ); |
114 | } |
115 | |
116 | $title = Title::newFromText( $target ); |
117 | if ( !$title ) { |
118 | $out->wrapWikiMsg( Html::errorBox( '$1' ), [ 'tpt-badtitle', $target ] ); |
119 | $out->addWikiMsg( 'tpt-list-pages-in-translations' ); |
120 | |
121 | return; |
122 | } elseif ( !$title->exists() ) { |
123 | $out->wrapWikiMsg( |
124 | Html::errorBox( '$1' ), |
125 | [ 'tpt-nosuchpage', $title->getPrefixedText() ] |
126 | ); |
127 | $out->addWikiMsg( 'tpt-list-pages-in-translations' ); |
128 | |
129 | return; |
130 | } |
131 | |
132 | // Check for blocks |
133 | $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); |
134 | if ( $permissionManager->isBlockedFrom( $user, $title, !$request->wasPosted() ) ) { |
135 | $block = $user->getBlock(); |
136 | if ( $block ) { |
137 | throw new UserBlockedError( |
138 | $block, |
139 | $user, |
140 | $this->getLanguage(), |
141 | $request->getIP() |
142 | ); |
143 | } |
144 | |
145 | throw new PermissionsError( 'pagetranslation', [ 'badaccess-group0' ] ); |
146 | |
147 | } |
148 | |
149 | // Check token for all POST actions here |
150 | $csrfTokenSet = $this->getContext()->getCsrfTokenSet(); |
151 | if ( $request->wasPosted() && !$csrfTokenSet->matchTokenField( 'token' ) ) { |
152 | throw new PermissionsError( 'pagetranslation' ); |
153 | } |
154 | |
155 | if ( $action === 'mark' ) { |
156 | // Has separate form |
157 | $this->onActionMark( $title, $revision ); |
158 | |
159 | return; |
160 | } |
161 | |
162 | // On GET requests, show form which has token |
163 | if ( !$request->wasPosted() ) { |
164 | if ( $action === 'unlink' ) { |
165 | $this->showUnlinkConfirmation( $title ); |
166 | } else { |
167 | $params = [ |
168 | 'do' => $action, |
169 | 'target' => $title->getPrefixedText(), |
170 | 'revision' => $revision, |
171 | ]; |
172 | $this->showGenericConfirmation( $params ); |
173 | } |
174 | |
175 | return; |
176 | } |
177 | |
178 | if ( $action === 'discourage' || $action === 'encourage' ) { |
179 | $id = TranslatablePage::getMessageGroupIdFromTitle( $title ); |
180 | $current = MessageGroups::getPriority( $id ); |
181 | |
182 | if ( $action === 'encourage' ) { |
183 | $new = ''; |
184 | } else { |
185 | $new = 'discouraged'; |
186 | } |
187 | |
188 | if ( $new !== $current ) { |
189 | MessageGroups::setPriority( $id, $new ); |
190 | $entry = new ManualLogEntry( 'pagetranslation', $action ); |
191 | $entry->setPerformer( $user ); |
192 | $entry->setTarget( $title ); |
193 | $logid = $entry->insert(); |
194 | $entry->publish( $logid ); |
195 | } |
196 | |
197 | // Defer stats purging of parent aggregate groups. Shared groups can contain other |
198 | // groups as well, which we do not need to update. We could filter non-aggregate |
199 | // groups out, or use MessageGroups::getParentGroups, though it has an inconvenient |
200 | // return value format for this use case. |
201 | $group = MessageGroups::getGroup( $id ); |
202 | $sharedGroupIds = MessageGroups::getSharedGroups( $group ); |
203 | if ( $sharedGroupIds !== [] ) { |
204 | $job = RebuildMessageGroupStatsJob::newRefreshGroupsJob( $sharedGroupIds ); |
205 | $this->jobQueueGroup->push( $job ); |
206 | } |
207 | |
208 | // Show updated page with a notice |
209 | $this->listPages(); |
210 | |
211 | return; |
212 | } |
213 | |
214 | if ( $action === 'unlink' || $action === 'unmark' ) { |
215 | try { |
216 | $this->translatablePageMarker->unmarkPage( |
217 | TranslatablePage::newFromTitle( $title ), |
218 | $user, |
219 | $action === 'unlink' |
220 | ); |
221 | |
222 | $out->wrapWikiMsg( |
223 | Html::successBox( '$1' ), |
224 | [ 'tpt-unmarked', $title->getPrefixedText() ] |
225 | ); |
226 | } catch ( TranslatablePageMarkException $e ) { |
227 | $out->wrapWikiMsg( |
228 | Html::errorBox( '$1' ), |
229 | $e->getMessageObject() |
230 | ); |
231 | } |
232 | |
233 | $out->addWikiMsg( 'tpt-list-pages-in-translations' ); |
234 | } |
235 | } |
236 | |
237 | protected function onActionMark( Title $title, ?int $revision ): void { |
238 | $request = $this->getRequest(); |
239 | $out = $this->getOutput(); |
240 | $translateTitle = $request->getCheck( 'translatetitle' ); |
241 | |
242 | try { |
243 | $operation = $this->translatablePageMarker->getMarkOperation( |
244 | $title->toPageRecord( |
245 | $request->wasPosted() ? IDBAccessObject::READ_LATEST : IDBAccessObject::READ_NORMAL |
246 | ), |
247 | $revision, |
248 | // If the request was not posted, validate all the units so that initially we display all the errors |
249 | // and then the user can choose whether they want to translate the title |
250 | !$request->wasPosted() || $translateTitle |
251 | ); |
252 | } catch ( TranslatablePageMarkException $e ) { |
253 | $out->addHTML( Html::errorBox( $this->msg( $e->getMessageObject() )->parse() ) ); |
254 | $out->addWikiMsg( 'tpt-list-pages-in-translations' ); |
255 | return; |
256 | } |
257 | |
258 | $unitNameValidationResult = $operation->getUnitValidationStatus(); |
259 | // Non-fatal error which prevents saving |
260 | if ( $unitNameValidationResult->isOK() && $request->wasPosted() ) { |
261 | // Fetch priority language related information |
262 | [ $priorityLanguages, $forcePriorityLanguage, $priorityLanguageReason ] = |
263 | $this->getPriorityLanguage( $this->getRequest() ); |
264 | |
265 | $noFuzzyUnits = array_filter( |
266 | preg_replace( |
267 | '/^tpt-sect-(.*)-action-nofuzzy$|.*/', |
268 | '$1', |
269 | array_keys( $request->getValues() ) |
270 | ), |
271 | 'strlen' |
272 | ); |
273 | |
274 | // https://www.php.net/manual/en/language.variables.external.php says: |
275 | // "Dots and spaces in variable names are converted to underscores. |
276 | // For example <input name="a b" /> becomes $_REQUEST["a_b"]." |
277 | // Therefore, we need to convert underscores back to spaces where they were used in section |
278 | // markers. |
279 | $noFuzzyUnits = str_replace( '_', ' ', $noFuzzyUnits ); |
280 | |
281 | $translatablePageSettings = new TranslatablePageSettings( |
282 | $priorityLanguages, |
283 | $forcePriorityLanguage, |
284 | $priorityLanguageReason, |
285 | $noFuzzyUnits, |
286 | $translateTitle, |
287 | $request->getCheck( 'use-latest-syntax' ), |
288 | $request->getCheck( 'transclusion' ) |
289 | ); |
290 | |
291 | try { |
292 | $unitCount = $this->translatablePageMarker->markForTranslation( |
293 | $operation, |
294 | $translatablePageSettings, |
295 | $this->getUser() |
296 | ); |
297 | $this->showSuccess( $operation->getPage(), $operation->isFirstMark(), $unitCount ); |
298 | } catch ( TranslatablePageMarkException $e ) { |
299 | $out->wrapWikiMsg( |
300 | Html::errorBox( '$1' ), |
301 | $e->getMessageObject() |
302 | ); |
303 | } |
304 | } else { |
305 | if ( !$unitNameValidationResult->isOK() ) { |
306 | $out->addHTML( |
307 | Html::errorBox( |
308 | $unitNameValidationResult->getHTML( false, false, $this->getLanguage() ) |
309 | ) |
310 | ); |
311 | } |
312 | |
313 | $this->showPage( $operation ); |
314 | } |
315 | } |
316 | |
317 | /** |
318 | * Displays success message and other instructions after a page has been marked for translation. |
319 | * @param TranslatablePage $page |
320 | * @param bool $firstMark true if it is the first time the page is being marked for translation. |
321 | * @param int $unitCount |
322 | * @return void |
323 | */ |
324 | private function showSuccess( |
325 | TranslatablePage $page, bool $firstMark, int $unitCount |
326 | ): void { |
327 | $titleText = $page->getTitle()->getPrefixedText(); |
328 | $num = $this->getLanguage()->formatNum( $unitCount ); |
329 | $link = SpecialPage::getTitleFor( 'Translate' )->getFullURL( [ |
330 | 'group' => $page->getMessageGroupId(), |
331 | 'action' => 'page', |
332 | 'filter' => '', |
333 | ] ); |
334 | |
335 | $this->getOutput()->wrapWikiMsg( |
336 | Html::successBox( '$1' ), |
337 | [ 'tpt-saveok', $titleText, $num, $link ] |
338 | ); |
339 | |
340 | // If the page is being marked for translation for the first time |
341 | // add a link to Special:PageMigration. |
342 | if ( $firstMark ) { |
343 | $this->getOutput()->addWikiMsg( 'tpt-saveok-first' ); |
344 | } |
345 | |
346 | // If TranslationNotifications is installed, and the user can notify |
347 | // translators, add a convenience link. |
348 | if ( method_exists( SpecialNotifyTranslators::class, 'execute' ) && |
349 | $this->getUser()->isAllowed( SpecialNotifyTranslators::$right ) |
350 | ) { |
351 | $link = SpecialPage::getTitleFor( 'NotifyTranslators' )->getFullURL( |
352 | [ 'tpage' => $page->getTitle()->getArticleID() ] |
353 | ); |
354 | $this->getOutput()->addWikiMsg( 'tpt-offer-notify', $link ); |
355 | } |
356 | |
357 | $this->getOutput()->addWikiMsg( 'tpt-list-pages-in-translations' ); |
358 | } |
359 | |
360 | protected function showGenericConfirmation( array $params ): void { |
361 | $formParams = [ |
362 | 'method' => 'post', |
363 | 'action' => $this->getPageTitle()->getLocalURL(), |
364 | ]; |
365 | |
366 | $params['title'] = $this->getPageTitle()->getPrefixedText(); |
367 | $params['token'] = $this->getContext()->getCsrfTokenSet()->getToken(); |
368 | |
369 | $hidden = ''; |
370 | foreach ( $params as $key => $value ) { |
371 | $hidden .= Html::hidden( $key, $value ); |
372 | } |
373 | |
374 | $this->getOutput()->addHTML( |
375 | Html::openElement( 'form', $formParams ) . |
376 | $hidden . |
377 | $this->msg( 'tpt-generic-confirm' )->parseAsBlock() . |
378 | Xml::submitButton( |
379 | $this->msg( 'tpt-generic-button' )->text(), |
380 | [ 'class' => 'mw-ui-button mw-ui-progressive' ] |
381 | ) . |
382 | Html::closeElement( 'form' ) |
383 | ); |
384 | } |
385 | |
386 | protected function showUnlinkConfirmation( Title $target ): void { |
387 | $formParams = [ |
388 | 'method' => 'post', |
389 | 'action' => $this->getPageTitle()->getLocalURL(), |
390 | ]; |
391 | |
392 | $this->getOutput()->addHTML( |
393 | Html::openElement( 'form', $formParams ) . |
394 | Html::hidden( 'do', 'unlink' ) . |
395 | Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . |
396 | Html::hidden( 'target', $target->getPrefixedText() ) . |
397 | Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() ) . |
398 | $this->msg( 'tpt-unlink-confirm', $target->getPrefixedText() )->parseAsBlock() . |
399 | Xml::submitButton( |
400 | $this->msg( 'tpt-unlink-button' )->text(), |
401 | [ 'class' => 'mw-ui-button mw-ui-destructive' ] |
402 | ) . |
403 | Html::closeElement( 'form' ) |
404 | ); |
405 | } |
406 | |
407 | /** |
408 | * TODO: Move this function to SyncTranslatableBundleStatusMaintenanceScript once we |
409 | * start using the translatable_bundles table for fetching the translatabale pages |
410 | */ |
411 | public static function loadPagesFromDB(): IResultWrapper { |
412 | $dbr = Utilities::getSafeReadDB(); |
413 | return $dbr->newSelectQueryBuilder() |
414 | ->select( [ |
415 | 'page_id', |
416 | 'page_namespace', |
417 | 'page_title', |
418 | 'page_latest', |
419 | 'rt_revision' => 'MAX(rt_revision)', |
420 | 'rt_type' |
421 | ] ) |
422 | ->from( 'page' ) |
423 | ->join( 'revtag', null, 'page_id=rt_page' ) |
424 | ->where( [ |
425 | 'rt_type' => [ RevTagStore::TP_MARK_TAG, RevTagStore::TP_READY_TAG ], |
426 | ] ) |
427 | ->orderBy( [ 'page_namespace', 'page_title' ] ) |
428 | ->groupBy( [ 'page_id', 'page_namespace', 'page_title', 'page_latest', 'rt_type' ] ) |
429 | ->caller( __METHOD__ ) |
430 | ->fetchResultSet(); |
431 | } |
432 | |
433 | /** |
434 | * TODO: Move this function to SyncTranslatableBundleStatusMaintenanceScript once we |
435 | * start using the translatable_bundles table for fetching the translatabale pages |
436 | */ |
437 | public static function buildPageArray( IResultWrapper $res ): array { |
438 | $pages = []; |
439 | foreach ( $res as $r ) { |
440 | // We have multiple rows for same page, because of different tags |
441 | if ( !isset( $pages[$r->page_id] ) ) { |
442 | $pages[$r->page_id] = []; |
443 | $title = Title::newFromRow( $r ); |
444 | $pages[$r->page_id]['title'] = $title; |
445 | $pages[$r->page_id]['latest'] = (int)$title->getLatestRevID(); |
446 | } |
447 | |
448 | $tag = $r->rt_type; |
449 | $pages[$r->page_id][$tag] = (int)$r->rt_revision; |
450 | } |
451 | |
452 | return $pages; |
453 | } |
454 | |
455 | /** |
456 | * Classify a list of pages and amend them with additional metadata. |
457 | * |
458 | * @param array[] $pages |
459 | * @return array[] |
460 | * @phan-return array{proposed:array[],active:array[],broken:array[],outdated:array[]} |
461 | */ |
462 | private function classifyPages( array $pages ): array { |
463 | // Preload stuff for performance |
464 | $messageGroupIdsForPreload = []; |
465 | foreach ( $pages as $i => $page ) { |
466 | $id = TranslatablePage::getMessageGroupIdFromTitle( $page['title'] ); |
467 | $messageGroupIdsForPreload[] = $id; |
468 | $pages[$i]['groupid'] = $id; |
469 | } |
470 | // Performance optimization: load only data we need to classify the pages |
471 | $metadata = $this->messageGroupMetadata->loadBasicMetadataForTranslatablePages( |
472 | $messageGroupIdsForPreload, |
473 | [ 'transclusion', 'version' ] |
474 | ); |
475 | |
476 | $out = [ |
477 | // The ideal state for pages: marked and up to date |
478 | 'active' => [], |
479 | 'proposed' => [], |
480 | 'outdated' => [], |
481 | 'broken' => [], |
482 | ]; |
483 | |
484 | foreach ( $pages as $page ) { |
485 | $groupId = $page['groupid']; |
486 | $group = MessageGroups::getGroup( $groupId ); |
487 | $page['discouraged'] = MessageGroups::getPriority( $group ) === 'discouraged'; |
488 | $page['version'] = $metadata[$groupId]['version'] ?? TranslatablePageMarker::DEFAULT_SYNTAX_VERSION; |
489 | $page['transclusion'] = $metadata[$groupId]['transclusion'] ?? false; |
490 | |
491 | // TODO: Eventually we should query the status directly from the TranslatableBundleStore |
492 | $tpStatus = TranslatablePage::determineStatus( |
493 | $page[RevTagStore::TP_READY_TAG] ?? null, |
494 | $page[RevTagStore::TP_MARK_TAG] ?? null, |
495 | $page['latest'] |
496 | ); |
497 | |
498 | if ( !$tpStatus ) { |
499 | // Ignore pages for which status could not be determined. |
500 | continue; |
501 | } |
502 | |
503 | $out[self::DISPLAY_STATUS_MAPPING[$tpStatus->getId()]][] = $page; |
504 | } |
505 | |
506 | return $out; |
507 | } |
508 | |
509 | public function listPages(): void { |
510 | $out = $this->getOutput(); |
511 | |
512 | $res = self::loadPagesFromDB(); |
513 | $allPages = self::buildPageArray( $res ); |
514 | if ( !count( $allPages ) ) { |
515 | $out->addWikiMsg( 'tpt-list-nopages' ); |
516 | |
517 | return; |
518 | } |
519 | |
520 | $lb = $this->linkBatchFactory->newLinkBatch(); |
521 | $lb->setCaller( __METHOD__ ); |
522 | foreach ( $allPages as $page ) { |
523 | $lb->addObj( $page['title'] ); |
524 | } |
525 | $lb->execute(); |
526 | |
527 | $types = $this->classifyPages( $allPages ); |
528 | |
529 | $pages = $types['proposed']; |
530 | if ( $pages ) { |
531 | $out->wrapWikiMsg( '== $1 ==', 'tpt-new-pages-title' ); |
532 | $out->addWikiMsg( 'tpt-new-pages', count( $pages ) ); |
533 | $out->addHTML( $this->getPageList( $pages, 'proposed' ) ); |
534 | } |
535 | |
536 | $pages = $types['broken']; |
537 | if ( $pages ) { |
538 | $out->wrapWikiMsg( '== $1 ==', 'tpt-other-pages-title' ); |
539 | $out->addWikiMsg( 'tpt-other-pages', count( $pages ) ); |
540 | $out->addHTML( $this->getPageList( $pages, 'broken' ) ); |
541 | } |
542 | |
543 | $pages = $types['outdated']; |
544 | if ( $pages ) { |
545 | $out->wrapWikiMsg( '== $1 ==', 'tpt-outdated-pages-title' ); |
546 | $out->addWikiMsg( 'tpt-outdated-pages', count( $pages ) ); |
547 | $out->addHTML( $this->getPageList( $pages, 'outdated' ) ); |
548 | } |
549 | |
550 | $pages = $types['active']; |
551 | if ( $pages ) { |
552 | $out->wrapWikiMsg( '== $1 ==', 'tpt-old-pages-title' ); |
553 | $out->addWikiMsg( 'tpt-old-pages', count( $pages ) ); |
554 | $out->addHTML( $this->getPageList( $pages, 'active' ) ); |
555 | } |
556 | } |
557 | |
558 | private function actionLinks( array $page, string $type ): string { |
559 | // Performance optimization to avoid calling $this->msg in a loop |
560 | static $messageCache = null; |
561 | if ( $messageCache === null ) { |
562 | $messageCache = [ |
563 | 'mark' => $this->msg( 'tpt-rev-mark' )->text(), |
564 | 'mark-tooltip' => $this->msg( 'tpt-rev-mark-tooltip' )->text(), |
565 | 'encourage' => $this->msg( 'tpt-rev-encourage' )->text(), |
566 | 'encourage-tooltip' => $this->msg( 'tpt-rev-encourage-tooltip' )->text(), |
567 | 'discourage' => $this->msg( 'tpt-rev-discourage' )->text(), |
568 | 'discourage-tooltip' => $this->msg( 'tpt-rev-discourage-tooltip' )->text(), |
569 | 'unmark' => $this->msg( 'tpt-rev-unmark' )->text(), |
570 | 'unmark-tooltip' => $this->msg( 'tpt-rev-unmark-tooltip' )->text(), |
571 | 'pipe-separator' => $this->msg( 'pipe-separator' )->escaped(), |
572 | ]; |
573 | } |
574 | |
575 | $actions = []; |
576 | /** @var Title $title */ |
577 | $title = $page['title']; |
578 | $user = $this->getUser(); |
579 | |
580 | // Class to allow one-click POSTs |
581 | $js = [ 'class' => 'mw-translate-jspost' ]; |
582 | |
583 | if ( $user->isAllowed( 'pagetranslation' ) ) { |
584 | // Enable re-marking of all pages to allow changing of priority languages |
585 | // or migration to the new syntax version |
586 | if ( $type !== 'broken' ) { |
587 | $actions[] = $this->getLinkRenderer()->makeKnownLink( |
588 | $this->getPageTitle(), |
589 | $messageCache['mark'], |
590 | [ 'title' => $messageCache['mark-tooltip'] ], |
591 | [ |
592 | 'do' => 'mark', |
593 | 'target' => $title->getPrefixedText(), |
594 | 'revision' => $title->getLatestRevID(), |
595 | ] |
596 | ); |
597 | } |
598 | |
599 | if ( $type !== 'proposed' ) { |
600 | if ( $page['discouraged'] ) { |
601 | $actions[] = $this->getLinkRenderer()->makeKnownLink( |
602 | $this->getPageTitle(), |
603 | $messageCache['encourage'], |
604 | [ 'title' => $messageCache['encourage-tooltip'] ] + $js, |
605 | [ |
606 | 'do' => 'encourage', |
607 | 'target' => $title->getPrefixedText(), |
608 | 'revision' => -1, |
609 | ] |
610 | ); |
611 | } else { |
612 | $actions[] = $this->getLinkRenderer()->makeKnownLink( |
613 | $this->getPageTitle(), |
614 | $messageCache['discourage'], |
615 | [ 'title' => $messageCache['discourage-tooltip'] ] + $js, |
616 | [ |
617 | 'do' => 'discourage', |
618 | 'target' => $title->getPrefixedText(), |
619 | 'revision' => -1, |
620 | ] |
621 | ); |
622 | } |
623 | |
624 | $actions[] = $this->getLinkRenderer()->makeKnownLink( |
625 | $this->getPageTitle(), |
626 | $messageCache['unmark'], |
627 | [ 'title' => $messageCache['unmark-tooltip'] ], |
628 | [ |
629 | 'do' => $type === 'broken' ? 'unmark' : 'unlink', |
630 | 'target' => $title->getPrefixedText(), |
631 | 'revision' => -1, |
632 | ] |
633 | ); |
634 | } |
635 | } |
636 | |
637 | if ( !$actions ) { |
638 | return ''; |
639 | } |
640 | |
641 | return '<div>' . implode( $messageCache['pipe-separator'], $actions ) . '</div>'; |
642 | } |
643 | |
644 | private function showPage( TranslatablePageMarkOperation $operation ): void { |
645 | $page = $operation->getPage(); |
646 | $out = $this->getOutput(); |
647 | $out->addBacklinkSubtitle( $page->getTitle() ); |
648 | $out->addWikiMsg( 'tpt-showpage-intro' ); |
649 | |
650 | $formParams = [ |
651 | 'method' => 'post', |
652 | 'action' => $this->getPageTitle()->getLocalURL(), |
653 | 'class' => 'mw-tpt-sp-markform', |
654 | ]; |
655 | |
656 | $out->addHTML( |
657 | Xml::openElement( 'form', $formParams ) . |
658 | Html::hidden( 'do', 'mark' ) . |
659 | Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . |
660 | Html::hidden( 'revision', $page->getRevision() ) . |
661 | Html::hidden( 'target', $page->getTitle()->getPrefixedText() ) . |
662 | Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() ) |
663 | ); |
664 | |
665 | $out->wrapWikiMsg( '==$1==', 'tpt-sections-oldnew' ); |
666 | |
667 | $diffOld = $this->msg( 'tpt-diff-old' )->escaped(); |
668 | $diffNew = $this->msg( 'tpt-diff-new' )->escaped(); |
669 | $hasChanges = false; |
670 | |
671 | // Check whether page title was previously marked for translation. |
672 | // If the page is marked for translation the first time, default to checked, |
673 | // unless the page is a template. T305240 |
674 | $defaultChecked = ( |
675 | $operation->isFirstMark() && |
676 | !$page->getTitle()->inNamespace( NS_TEMPLATE ) |
677 | ) || $page->hasPageDisplayTitle(); |
678 | |
679 | $sourceLanguage = $this->languageFactory->getLanguage( $page->getSourceLanguageCode() ); |
680 | |
681 | foreach ( $operation->getUnits() as $s ) { |
682 | if ( $s->id === TranslatablePage::DISPLAY_TITLE_UNIT_ID ) { |
683 | // Set section type as new if title previously unchecked |
684 | $s->type = $defaultChecked ? $s->type : 'new'; |
685 | |
686 | // Checkbox for page title optional translation |
687 | $checkBox = new FieldLayout( |
688 | new CheckboxInputWidget( [ |
689 | 'name' => 'translatetitle', |
690 | 'selected' => $defaultChecked, |
691 | ] ), |
692 | [ |
693 | 'label' => $this->msg( 'tpt-translate-title' )->text(), |
694 | 'align' => 'inline', |
695 | 'classes' => [ 'mw-tpt-m-vertical' ] |
696 | ] |
697 | ); |
698 | $out->addHTML( $checkBox->toString() ); |
699 | } |
700 | |
701 | if ( $s->type === 'new' ) { |
702 | $hasChanges = true; |
703 | $name = $this->msg( 'tpt-section-new', $s->id )->escaped(); |
704 | } else { |
705 | $name = $this->msg( 'tpt-section', $s->id )->escaped(); |
706 | } |
707 | |
708 | if ( $s->type === 'changed' ) { |
709 | $hasChanges = true; |
710 | $diff = new DifferenceEngine(); |
711 | $diff->setTextLanguage( $sourceLanguage ); |
712 | $diff->setReducedLineNumbers(); |
713 | |
714 | $oldContent = ContentHandler::makeContent( $s->getOldText(), $diff->getTitle() ); |
715 | $newContent = ContentHandler::makeContent( $s->getText(), $diff->getTitle() ); |
716 | |
717 | $diff->setContent( $oldContent, $newContent ); |
718 | |
719 | $text = $diff->getDiff( $diffOld, $diffNew ); |
720 | $diffOld = $diffNew = null; |
721 | $diff->showDiffStyle(); |
722 | |
723 | $id = "tpt-sect-{$s->id}-action-nofuzzy"; |
724 | $checkLabel = new FieldLayout( |
725 | new CheckboxInputWidget( [ |
726 | 'name' => $id, |
727 | 'selected' => false, |
728 | ] ), |
729 | [ |
730 | 'label' => $this->msg( 'tpt-action-nofuzzy' )->text(), |
731 | 'align' => 'inline', |
732 | 'classes' => [ 'mw-tpt-m-vertical' ] |
733 | ] |
734 | ); |
735 | $text = $checkLabel->toString() . $text; |
736 | } else { |
737 | $text = Utilities::convertWhiteSpaceToHTML( $s->getText() ); |
738 | } |
739 | |
740 | # For changed text, the language is set by $diff->setTextLanguage() |
741 | $lang = $s->type === 'changed' ? null : $sourceLanguage; |
742 | $out->addHTML( MessageWebImporter::makeSectionElement( |
743 | $name, |
744 | $s->type, |
745 | $text, |
746 | $lang |
747 | ) ); |
748 | |
749 | foreach ( $s->getIssues() as $issue ) { |
750 | $severity = $issue->getSeverity(); |
751 | if ( $severity === TranslationUnitIssue::WARNING ) { |
752 | $box = Html::warningBox( $this->msg( $issue )->escaped() ); |
753 | } elseif ( $severity === TranslationUnitIssue::ERROR ) { |
754 | $box = Html::errorBox( $this->msg( $issue )->escaped() ); |
755 | } else { |
756 | throw new UnexpectedValueException( |
757 | "Unknown severity: $severity for key: {$issue->getKey()}" |
758 | ); |
759 | } |
760 | |
761 | $out->addHTML( $box ); |
762 | } |
763 | } |
764 | |
765 | if ( $operation->getDeletedUnits() ) { |
766 | $hasChanges = true; |
767 | $out->wrapWikiMsg( '==$1==', 'tpt-sections-deleted' ); |
768 | |
769 | foreach ( $operation->getDeletedUnits() as $s ) { |
770 | $name = $this->msg( 'tpt-section-deleted', $s->id )->escaped(); |
771 | $text = Utilities::convertWhiteSpaceToHTML( $s->getText() ); |
772 | $out->addHTML( MessageWebImporter::makeSectionElement( |
773 | $name, |
774 | 'deleted', |
775 | $text, |
776 | $sourceLanguage |
777 | ) ); |
778 | } |
779 | } |
780 | |
781 | // Display template changes if applicable |
782 | $markedTag = $page->getMarkedTag(); |
783 | if ( $markedTag !== null ) { |
784 | $hasChanges = true; |
785 | $newTemplate = $operation->getParserOutput()->sourcePageTemplateForDiffs(); |
786 | $oldPage = TranslatablePage::newFromRevision( |
787 | $page->getTitle(), |
788 | $markedTag |
789 | ); |
790 | $oldTemplate = $this->translatablePageParser |
791 | ->parse( $oldPage->getText() ) |
792 | ->sourcePageTemplateForDiffs(); |
793 | |
794 | if ( $oldTemplate !== $newTemplate ) { |
795 | $out->wrapWikiMsg( '==$1==', 'tpt-sections-template' ); |
796 | |
797 | $diff = new DifferenceEngine(); |
798 | $diff->setTextLanguage( $sourceLanguage ); |
799 | |
800 | $oldContent = ContentHandler::makeContent( $oldTemplate, $diff->getTitle() ); |
801 | $newContent = ContentHandler::makeContent( $newTemplate, $diff->getTitle() ); |
802 | |
803 | $diff->setContent( $oldContent, $newContent ); |
804 | |
805 | $text = $diff->getDiff( |
806 | $this->msg( 'tpt-diff-old' )->escaped(), |
807 | $this->msg( 'tpt-diff-new' )->escaped() |
808 | ); |
809 | $diff->showDiffStyle(); |
810 | $diff->setReducedLineNumbers(); |
811 | |
812 | $out->addHTML( Xml::tags( 'div', [], $text ) ); |
813 | } |
814 | } |
815 | |
816 | if ( !$hasChanges ) { |
817 | $out->wrapWikiMsg( Html::successBox( '$1' ), 'tpt-mark-nochanges' ); |
818 | } |
819 | |
820 | $this->priorityLanguagesForm( $page ); |
821 | |
822 | // If an existing page does not have the supportsTransclusion flag, keep the checkbox unchecked, |
823 | // If the page is being marked for translation for the first time, the checkbox can be checked |
824 | $this->templateTransclusionForm( $page->supportsTransclusion() ?? $operation->isFirstMark() ); |
825 | |
826 | $version = $this->messageGroupMetadata->getWithDefaultValue( |
827 | $page->getMessageGroupId(), 'version', TranslatablePageMarker::DEFAULT_SYNTAX_VERSION |
828 | ); |
829 | $this->syntaxVersionForm( $version, $operation->isFirstMark() ); |
830 | |
831 | $submitButton = new FieldLayout( |
832 | new ButtonInputWidget( [ |
833 | 'label' => $this->msg( 'tpt-submit' )->text(), |
834 | 'type' => 'submit', |
835 | 'flags' => [ 'primary', 'progressive' ], |
836 | ] ), |
837 | [ |
838 | 'label' => null, |
839 | 'align' => 'top', |
840 | ] |
841 | ); |
842 | |
843 | $out->addHTML( $submitButton->toString() ); |
844 | $out->addHTML( '</form>' ); |
845 | } |
846 | |
847 | private function priorityLanguagesForm( TranslatablePage $page ): void { |
848 | $groupId = $page->getMessageGroupId(); |
849 | $interfaceLanguage = $this->getLanguage()->getCode(); |
850 | $storedLanguages = (string)$this->messageGroupMetadata->get( $groupId, 'prioritylangs' ); |
851 | $default = $storedLanguages !== '' ? explode( ',', $storedLanguages ) : []; |
852 | |
853 | $priorityReason = $this->messageGroupMetadata->get( $groupId, 'priorityreason' ); |
854 | $priorityReason = $priorityReason !== false ? $priorityReason : ''; |
855 | |
856 | $form = new FieldsetLayout( [ |
857 | 'items' => [ |
858 | new FieldLayout( |
859 | new LanguagesMultiselectWidget( [ |
860 | 'infusable' => true, |
861 | 'name' => 'prioritylangs', |
862 | 'id' => 'mw-translate-SpecialPageTranslation-prioritylangs', |
863 | 'languages' => Utilities::getLanguageNames( $interfaceLanguage ), |
864 | 'default' => $default, |
865 | ] ), |
866 | [ |
867 | 'label' => $this->msg( 'tpt-select-prioritylangs' )->text(), |
868 | 'align' => 'top', |
869 | ] |
870 | ), |
871 | new FieldLayout( |
872 | new CheckboxInputWidget( [ |
873 | 'name' => 'forcelimit', |
874 | 'selected' => $this->messageGroupMetadata->get( $groupId, 'priorityforce' ) === 'on', |
875 | ] ), |
876 | [ |
877 | 'label' => $this->msg( 'tpt-select-prioritylangs-force' )->text(), |
878 | 'align' => 'inline', |
879 | 'help' => new HtmlSnippet( $this->msg( 'tpt-select-no-prioritylangs-force' )->parse() ), |
880 | ] |
881 | ), |
882 | new FieldLayout( |
883 | new TextInputWidget( [ |
884 | 'name' => 'priorityreason', |
885 | 'value' => $priorityReason |
886 | ] ), |
887 | [ |
888 | 'label' => $this->msg( 'tpt-select-prioritylangs-reason' )->text(), |
889 | 'align' => 'top', |
890 | ] |
891 | ), |
892 | |
893 | ], |
894 | ] ); |
895 | |
896 | $this->getOutput()->wrapWikiMsg( '==$1==', 'tpt-sections-prioritylangs' ); |
897 | $this->getOutput()->addHTML( $form->toString() ); |
898 | } |
899 | |
900 | private function syntaxVersionForm( string $version, bool $firstMark ): void { |
901 | $out = $this->getOutput(); |
902 | |
903 | if ( $version === TranslatablePageMarker::LATEST_SYNTAX_VERSION || $firstMark ) { |
904 | return; |
905 | } |
906 | |
907 | $out->wrapWikiMsg( '==$1==', 'tpt-sections-syntaxversion' ); |
908 | $out->addWikiMsg( |
909 | 'tpt-syntaxversion-text', |
910 | '<code>' . wfEscapeWikiText( '<span lang="en" dir="ltr">...</span>' ) . '</code>', |
911 | '<code>' . wfEscapeWikiText( '<translate nowrap>...</translate>' ) . '</code>' |
912 | ); |
913 | |
914 | $checkBox = new FieldLayout( |
915 | new CheckboxInputWidget( [ |
916 | 'name' => 'use-latest-syntax' |
917 | ] ), |
918 | [ |
919 | 'label' => $out->msg( 'tpt-syntaxversion-label' )->text(), |
920 | 'align' => 'inline', |
921 | ] |
922 | ); |
923 | |
924 | $out->addHTML( $checkBox->toString() ); |
925 | } |
926 | |
927 | private function templateTransclusionForm( bool $supportsTransclusion ): void { |
928 | $out = $this->getOutput(); |
929 | $out->wrapWikiMsg( '==$1==', 'tpt-transclusion' ); |
930 | |
931 | $checkBox = new FieldLayout( |
932 | new CheckboxInputWidget( [ |
933 | 'name' => 'transclusion', |
934 | 'selected' => $supportsTransclusion |
935 | ] ), |
936 | [ |
937 | 'label' => $out->msg( 'tpt-transclusion-label' )->text(), |
938 | 'align' => 'inline', |
939 | ] |
940 | ); |
941 | |
942 | $out->addHTML( $checkBox->toString() ); |
943 | } |
944 | |
945 | private function getPriorityLanguage( WebRequest $request ): array { |
946 | // Get the priority languages from the request |
947 | // We've to do some extra work here because if JS is disabled, we will be getting |
948 | // the values split by newline. |
949 | $priorityLanguages = rtrim( trim( $request->getVal( 'prioritylangs', '' ) ), ',' ); |
950 | $priorityLanguages = str_replace( "\n", ',', $priorityLanguages ); |
951 | $priorityLanguages = array_map( 'trim', explode( ',', $priorityLanguages ) ); |
952 | $priorityLanguages = array_unique( array_filter( $priorityLanguages ) ); |
953 | |
954 | $forcePriorityLanguage = $request->getCheck( 'forcelimit' ); |
955 | $priorityLanguageReason = trim( $request->getText( 'priorityreason' ) ); |
956 | |
957 | return [ $priorityLanguages, $forcePriorityLanguage, $priorityLanguageReason ]; |
958 | } |
959 | |
960 | private function getPageList( array $pages, string $type ): string { |
961 | $items = []; |
962 | $tagsTextCache = []; |
963 | |
964 | $tagDiscouraged = $this->msg( 'tpt-tag-discouraged' )->escaped(); |
965 | $tagOldSyntax = $this->msg( 'tpt-tag-oldsyntax' )->escaped(); |
966 | $tagNoTransclusionSupport = $this->msg( 'tpt-tag-no-transclusion-support' )->escaped(); |
967 | |
968 | foreach ( $pages as $page ) { |
969 | $link = $this->getLinkRenderer()->makeKnownLink( $page['title'] ); |
970 | $acts = $this->actionLinks( $page, $type ); |
971 | $tags = []; |
972 | if ( $page['discouraged'] ) { |
973 | $tags[] = $tagDiscouraged; |
974 | } |
975 | if ( $type !== 'proposed' ) { |
976 | if ( $page['version'] !== TranslatablePageMarker::LATEST_SYNTAX_VERSION ) { |
977 | $tags[] = $tagOldSyntax; |
978 | } |
979 | |
980 | if ( $page['transclusion'] !== '1' ) { |
981 | $tags[] = $tagNoTransclusionSupport; |
982 | } |
983 | } |
984 | |
985 | $tagList = ''; |
986 | if ( $tags ) { |
987 | // Performance optimization to avoid calling $this->msg in a loop |
988 | $tagsKey = implode( '', $tags ); |
989 | $tagsTextCache[$tagsKey] ??= $this->msg( 'parentheses' ) |
990 | ->rawParams( $this->getLanguage()->pipeList( $tags ) ) |
991 | ->escaped(); |
992 | |
993 | $tagList = Html::rawElement( |
994 | 'span', |
995 | [ 'class' => 'mw-tpt-actions' ], |
996 | $tagsTextCache[$tagsKey] |
997 | ); |
998 | } |
999 | |
1000 | $items[] = "<li class='mw-tpt-pagelist-item'>$link $tagList $acts</li>"; |
1001 | } |
1002 | |
1003 | return '<ol>' . implode( "", $items ) . '</ol>'; |
1004 | } |
1005 | } |