Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
PageTranslationSpecialPage.php
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\PageTranslation;
5
6use ContentHandler;
7use DifferenceEngine;
8use ErrorPageError;
9use IDBAccessObject;
10use InvalidArgumentException;
11use JobQueueGroup;
12use ManualLogEntry;
13use MediaWiki\Cache\LinkBatchFactory;
22use MediaWiki\Extension\TranslationNotifications\SpecialNotifyTranslators;
23use MediaWiki\Html\Html;
24use MediaWiki\Languages\LanguageFactory;
25use MediaWiki\Page\PageRecord;
26use MediaWiki\Permissions\PermissionManager;
27use MediaWiki\SpecialPage\SpecialPage;
28use MediaWiki\Title\Title;
29use OOUI\ButtonInputWidget;
30use OOUI\CheckboxInputWidget;
31use OOUI\FieldLayout;
32use OOUI\FieldsetLayout;
33use OOUI\HtmlSnippet;
34use OOUI\RadioInputWidget;
35use OOUI\TextInputWidget;
36use PermissionsError;
37use UnexpectedValueException;
38use User;
39use UserBlockedError;
40use WebRequest;
41use Wikimedia\Rdbms\IResultWrapper;
42use Xml;
43use function count;
44use function wfEscapeWikiText;
45
57class PageTranslationSpecialPage extends SpecialPage {
58 private const DISPLAY_STATUS_MAPPING = [
59 TranslatablePageStatus::PROPOSED => 'proposed',
60 TranslatablePageStatus::ACTIVE => 'active',
61 TranslatablePageStatus::OUTDATED => 'outdated',
62 TranslatablePageStatus::BROKEN => 'broken'
63 ];
64 private LanguageFactory $languageFactory;
65 private LinkBatchFactory $linkBatchFactory;
66 private JobQueueGroup $jobQueueGroup;
67 private PermissionManager $permissionManager;
68 private TranslatablePageMarker $translatablePageMarker;
69 private TranslatablePageParser $translatablePageParser;
70 private MessageGroupMetadata $messageGroupMetadata;
71 private TranslatablePageView $translatablePageView;
72 private TranslatablePageStateStore $translatablePageStateStore;
73
74 public function __construct(
75 LanguageFactory $languageFactory,
76 LinkBatchFactory $linkBatchFactory,
77 JobQueueGroup $jobQueueGroup,
78 PermissionManager $permissionManager,
79 TranslatablePageMarker $translatablePageMarker,
80 TranslatablePageParser $translatablePageParser,
81 MessageGroupMetadata $messageGroupMetadata,
82 TranslatablePageView $translatablePageView,
83 TranslatablePageStateStore $translatablePageStateStore
84 ) {
85 parent::__construct( 'PageTranslation' );
86 $this->languageFactory = $languageFactory;
87 $this->linkBatchFactory = $linkBatchFactory;
88 $this->jobQueueGroup = $jobQueueGroup;
89 $this->permissionManager = $permissionManager;
90 $this->translatablePageMarker = $translatablePageMarker;
91 $this->translatablePageParser = $translatablePageParser;
92 $this->messageGroupMetadata = $messageGroupMetadata;
93 $this->translatablePageView = $translatablePageView;
94 $this->translatablePageStateStore = $translatablePageStateStore;
95 }
96
97 public function doesWrites(): bool {
98 return true;
99 }
100
101 protected function getGroupName(): string {
102 return 'translation';
103 }
104
105 public function execute( $parameters ) {
106 $this->setHeaders();
107
108 $user = $this->getUser();
109 $request = $this->getRequest();
110
111 $target = $request->getText( 'target', $parameters ?? '' );
112 $revision = $request->getIntOrNull( 'revision' );
113 $action = $request->getVal( 'do' );
114 $out = $this->getOutput();
115 $out->addModules( 'ext.translate.special.pagetranslation' );
116 $out->addModuleStyles( 'ext.translate.specialpages.styles' );
117 $out->addHelpLink( 'Help:Extension:Translate/Page_translation_example' );
118 $out->enableOOUI();
119
120 if ( $target === '' ) {
121 $this->listPages();
122
123 return;
124 }
125
126 $title = Title::newFromText( $target );
127 if ( !$title ) {
128 $out->wrapWikiMsg( Html::errorBox( '$1' ), [ 'tpt-badtitle', $target ] );
129 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
130
131 return;
132 } elseif ( !$title->exists() ) {
133 $out->wrapWikiMsg(
134 Html::errorBox( '$1' ),
135 [ 'tpt-nosuchpage', $title->getPrefixedText() ]
136 );
137 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
138
139 return;
140 }
141
142 if ( $action === 'settings' && !$this->translatablePageView->isTranslationBannerNamespaceConfigured() ) {
143 $this->showTranslationStateRestricted();
144 return;
145 }
146
147 $block = $this->getBlock( $request, $user, $title );
148 if ( $action === 'settings' && !$request->wasPosted() ) {
149 $this->showTranslationSettings( $title, $block );
150 return;
151 }
152
153 if ( $block ) {
154 throw $block;
155 }
156
157 // Check token for all POST actions here
158 $csrfTokenSet = $this->getContext()->getCsrfTokenSet();
159 if ( $request->wasPosted() && !$csrfTokenSet->matchTokenField( 'token' ) ) {
160 throw new PermissionsError( 'pagetranslation' );
161 }
162
163 if ( $action === 'settings' && $request->wasPosted() ) {
164 $this->handleTranslationState( $title, $request->getRawVal( 'translatable-page-state' ) );
165 return;
166 }
167
168 // Anything other than listing the pages or manipulating settings needs permissions
169 if ( !$user->isAllowed( 'pagetranslation' ) ) {
170 throw new PermissionsError( 'pagetranslation' );
171 }
172
173 if ( $action === 'mark' ) {
174 // Has separate form
175 $this->onActionMark( $title, $revision );
176
177 return;
178 }
179
180 // On GET requests, show form which has token
181 if ( !$request->wasPosted() ) {
182 if ( $action === 'unlink' ) {
183 $this->showUnlinkConfirmation( $title );
184 } else {
185 $params = [
186 'do' => $action,
187 'target' => $title->getPrefixedText(),
188 'revision' => $revision,
189 ];
190 $this->showGenericConfirmation( $params );
191 }
192
193 return;
194 }
195
196 if ( $action === 'discourage' || $action === 'encourage' ) {
198 $current = MessageGroups::getPriority( $id );
199
200 if ( $action === 'encourage' ) {
201 $new = '';
202 } else {
203 $new = 'discouraged';
204 }
205
206 if ( $new !== $current ) {
207 MessageGroups::setPriority( $id, $new );
208 $entry = new ManualLogEntry( 'pagetranslation', $action );
209 $entry->setPerformer( $user );
210 $entry->setTarget( $title );
211 $logId = $entry->insert();
212 $entry->publish( $logId );
213 }
214
215 // Defer stats purging of parent aggregate groups. Shared groups can contain other
216 // groups as well, which we do not need to update. We could filter non-aggregate
217 // groups out, or use MessageGroups::getParentGroups, though it has an inconvenient
218 // return value format for this use case.
219 $group = MessageGroups::getGroup( $id );
220 $sharedGroupIds = MessageGroups::getSharedGroups( $group );
221 if ( $sharedGroupIds !== [] ) {
222 $job = RebuildMessageGroupStatsJob::newRefreshGroupsJob( $sharedGroupIds );
223 $this->jobQueueGroup->push( $job );
224 }
225
226 // Show updated page with a notice
227 $this->listPages();
228
229 return;
230 }
231
232 if ( $action === 'unlink' || $action === 'unmark' ) {
233 try {
234 $this->translatablePageMarker->unmarkPage(
236 $user,
237 $action === 'unlink'
238 );
239
240 $out->wrapWikiMsg(
241 Html::successBox( '$1' ),
242 [ 'tpt-unmarked', $title->getPrefixedText() ]
243 );
244 } catch ( TranslatablePageMarkException $e ) {
245 $out->wrapWikiMsg(
246 Html::errorBox( '$1' ),
247 $e->getMessageObject()
248 );
249 }
250
251 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
252 }
253 }
254
255 protected function onActionMark( Title $title, ?int $revision ): void {
256 $request = $this->getRequest();
257 $out = $this->getOutput();
258 $translateTitle = $request->getCheck( 'translatetitle' );
259
260 try {
261 $operation = $this->translatablePageMarker->getMarkOperation(
262 $title->toPageRecord(
263 $request->wasPosted() ? IDBAccessObject::READ_LATEST : IDBAccessObject::READ_NORMAL
264 ),
265 $revision,
266 // If the request was not posted, validate all the units so that initially we display all the errors
267 // and then the user can choose whether they want to translate the title
268 !$request->wasPosted() || $translateTitle
269 );
270 } catch ( TranslatablePageMarkException $e ) {
271 $out->addHTML( Html::errorBox( $this->msg( $e->getMessageObject() )->parse() ) );
272 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
273 return;
274 }
275
276 $unitNameValidationResult = $operation->getUnitValidationStatus();
277 // Non-fatal error which prevents saving
278 if ( $unitNameValidationResult->isOK() && $request->wasPosted() ) {
279 // Fetch priority language related information
280 [ $priorityLanguages, $forcePriorityLanguage, $priorityLanguageReason ] =
281 $this->getPriorityLanguage( $this->getRequest() );
282
283 $noFuzzyUnits = array_filter(
284 preg_replace(
285 '/^tpt-sect-(.*)-action-nofuzzy$|.*/',
286 '$1',
287 array_keys( $request->getValues() )
288 ),
289 'strlen'
290 );
291
292 // https://www.php.net/manual/en/language.variables.external.php says:
293 // "Dots and spaces in variable names are converted to underscores.
294 // For example <input name="a b" /> becomes $_REQUEST["a_b"]."
295 // Therefore, we need to convert underscores back to spaces where they were used in section
296 // markers.
297 $noFuzzyUnits = str_replace( '_', ' ', $noFuzzyUnits );
298
299 $translatablePageSettings = new TranslatablePageSettings(
300 $priorityLanguages,
301 $forcePriorityLanguage,
302 $priorityLanguageReason,
303 $noFuzzyUnits,
304 $translateTitle,
305 $request->getCheck( 'use-latest-syntax' ),
306 $request->getCheck( 'transclusion' )
307 );
308
309 try {
310 $unitCount = $this->translatablePageMarker->markForTranslation(
311 $operation,
312 $translatablePageSettings,
313 $this->getUser()
314 );
315 $this->showSuccess( $operation->getPage(), $operation->isFirstMark(), $unitCount );
316 } catch ( TranslatablePageMarkException $e ) {
317 $out->wrapWikiMsg(
318 Html::errorBox( '$1' ),
319 $e->getMessageObject()
320 );
321 }
322 } else {
323 if ( !$unitNameValidationResult->isOK() ) {
324 $out->addHTML(
325 Html::errorBox(
326 $unitNameValidationResult->getHTML( false, false, $this->getLanguage() )
327 )
328 );
329 }
330
331 $this->showPage( $operation );
332 }
333 }
334
342 private function showSuccess( TranslatablePage $page, bool $firstMark, int $unitCount ): void {
343 $titleText = $page->getTitle()->getPrefixedText();
344 $num = $this->getLanguage()->formatNum( $unitCount );
345 $link = SpecialPage::getTitleFor( 'Translate' )->getFullURL( [
346 'group' => $page->getMessageGroupId(),
347 'action' => 'page',
348 'filter' => '',
349 ] );
350
351 $this->getOutput()->wrapWikiMsg(
352 Html::successBox( '$1' ),
353 [ 'tpt-saveok', $titleText, $num, $link ]
354 );
355
356 // If the page is being marked for translation for the first time
357 // add a link to Special:PageMigration.
358 if ( $firstMark ) {
359 $this->getOutput()->addWikiMsg( 'tpt-saveok-first' );
360 }
361
362 // If TranslationNotifications is installed, and the user can notify
363 // translators, add a convenience link.
364 if ( method_exists( SpecialNotifyTranslators::class, 'execute' ) &&
365 $this->getUser()->isAllowed( SpecialNotifyTranslators::$right )
366 ) {
367 $link = SpecialPage::getTitleFor( 'NotifyTranslators' )->getFullURL(
368 [ 'tpage' => $page->getTitle()->getArticleID() ]
369 );
370 $this->getOutput()->addWikiMsg( 'tpt-offer-notify', $link );
371 }
372
373 $this->getOutput()->addWikiMsg( 'tpt-list-pages-in-translations' );
374 }
375
376 private function showGenericConfirmation( array $params ): void {
377 $formParams = [
378 'method' => 'post',
379 'action' => $this->getPageTitle()->getLocalURL(),
380 ];
381
382 $params['title'] = $this->getPageTitle()->getPrefixedText();
383 $params['token'] = $this->getContext()->getCsrfTokenSet()->getToken();
384
385 $hidden = '';
386 foreach ( $params as $key => $value ) {
387 $hidden .= Html::hidden( $key, $value );
388 }
389
390 $this->getOutput()->addHTML(
391 Html::openElement( 'form', $formParams ) .
392 $hidden .
393 $this->msg( 'tpt-generic-confirm' )->parseAsBlock() .
394 Xml::submitButton(
395 $this->msg( 'tpt-generic-button' )->text(),
396 [ 'class' => 'mw-ui-button mw-ui-progressive' ]
397 ) .
398 Html::closeElement( 'form' )
399 );
400 }
401
402 private function showUnlinkConfirmation( Title $target ): void {
403 $formParams = [
404 'method' => 'post',
405 'action' => $this->getPageTitle()->getLocalURL(),
406 ];
407
408 $this->getOutput()->addHTML(
409 Html::openElement( 'form', $formParams ) .
410 Html::hidden( 'do', 'unlink' ) .
411 Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
412 Html::hidden( 'target', $target->getPrefixedText() ) .
413 Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() ) .
414 $this->msg( 'tpt-unlink-confirm', $target->getPrefixedText() )->parseAsBlock() .
415 Xml::submitButton(
416 $this->msg( 'tpt-unlink-button' )->text(),
417 [ 'class' => 'mw-ui-button mw-ui-destructive' ]
418 ) .
419 Html::closeElement( 'form' )
420 );
421 }
422
427 public static function loadPagesFromDB(): IResultWrapper {
428 $dbr = Utilities::getSafeReadDB();
429 return $dbr->newSelectQueryBuilder()
430 ->select( [
431 'page_id',
432 'page_namespace',
433 'page_title',
434 'page_latest',
435 'rt_revision' => 'MAX(rt_revision)',
436 'rt_type'
437 ] )
438 ->from( 'page' )
439 ->join( 'revtag', null, 'page_id=rt_page' )
440 ->where( [
441 'rt_type' => [ RevTagStore::TP_MARK_TAG, RevTagStore::TP_READY_TAG ],
442 ] )
443 ->orderBy( [ 'page_namespace', 'page_title' ] )
444 ->groupBy( [ 'page_id', 'page_namespace', 'page_title', 'page_latest', 'rt_type' ] )
445 ->caller( __METHOD__ )
446 ->fetchResultSet();
447 }
448
453 public static function buildPageArray( IResultWrapper $res ): array {
454 $pages = [];
455 foreach ( $res as $r ) {
456 // We have multiple rows for same page, because of different tags
457 if ( !isset( $pages[$r->page_id] ) ) {
458 $pages[$r->page_id] = [];
459 $title = Title::newFromRow( $r );
460 $pages[$r->page_id]['title'] = $title;
461 $pages[$r->page_id]['latest'] = (int)$title->getLatestRevID();
462 }
463
464 $tag = $r->rt_type;
465 $pages[$r->page_id][$tag] = (int)$r->rt_revision;
466 }
467
468 return $pages;
469 }
470
477 private function classifyPages( array $pages ): array {
478 $out = [
479 // The ideal state for pages: marked and up to date
480 'active' => [],
481 'proposed' => [],
482 'outdated' => [],
483 'broken' => [],
484 ];
485
486 if ( $pages === [] ) {
487 return $out;
488 }
489
490 // Preload stuff for performance
491 $messageGroupIdsForPreload = [];
492 foreach ( $pages as $i => $page ) {
493 $id = TranslatablePage::getMessageGroupIdFromTitle( $page['title'] );
494 $messageGroupIdsForPreload[] = $id;
495 $pages[$i]['groupid'] = $id;
496 }
497 // Performance optimization: load only data we need to classify the pages
498 $metadata = $this->messageGroupMetadata->loadBasicMetadataForTranslatablePages(
499 $messageGroupIdsForPreload,
500 [ 'transclusion', 'version' ]
501 );
502
503 foreach ( $pages as $page ) {
504 $groupId = $page['groupid'];
505 $group = MessageGroups::getGroup( $groupId );
506 $page['discouraged'] = MessageGroups::getPriority( $group ) === 'discouraged';
507 $page['version'] = $metadata[$groupId]['version'] ?? TranslatablePageMarker::DEFAULT_SYNTAX_VERSION;
508 $page['transclusion'] = $metadata[$groupId]['transclusion'] ?? false;
509
510 // TODO: Eventually we should query the status directly from the TranslatableBundleStore
511 $tpStatus = TranslatablePage::determineStatus(
512 $page[RevTagStore::TP_READY_TAG] ?? null,
513 $page[RevTagStore::TP_MARK_TAG] ?? null,
514 $page['latest']
515 );
516
517 if ( !$tpStatus ) {
518 // Ignore pages for which status could not be determined.
519 continue;
520 }
521
522 $out[self::DISPLAY_STATUS_MAPPING[$tpStatus->getId()]][] = $page;
523 }
524
525 return $out;
526 }
527
528 public function listPages(): void {
529 $out = $this->getOutput();
530
531 $res = self::loadPagesFromDB();
532 $allPages = self::buildPageArray( $res );
533
534 $pagesWithProposedState = [];
535 if ( $this->translatablePageView->isTranslationBannerNamespaceConfigured() ) {
536 $pagesWithProposedState = $this->translatablePageStateStore->getRequested();
537 }
538
539 if ( !count( $allPages ) && !count( $pagesWithProposedState ) ) {
540 $out->addWikiMsg( 'tpt-list-nopages' );
541
542 return;
543 }
544
545 $lb = $this->linkBatchFactory->newLinkBatch();
546 $lb->setCaller( __METHOD__ );
547 foreach ( $allPages as $page ) {
548 $lb->addObj( $page['title'] );
549 }
550
551 foreach ( $pagesWithProposedState as $title ) {
552 $lb->addObj( $title );
553 }
554 $lb->execute();
555
556 $types = $this->classifyPages( $allPages );
557
558 $pages = $types['proposed'];
559 if ( $pages || $pagesWithProposedState ) {
560 $out->wrapWikiMsg( '== $1 ==', 'tpt-new-pages-title' );
561 if ( $pages ) {
562 $out->addWikiMsg( 'tpt-new-pages', count( $pages ) );
563 $out->addHTML( $this->getPageList( $pages, 'proposed' ) );
564 }
565
566 if ( $pagesWithProposedState ) {
567 $out->addWikiMsg( 'tpt-proposed-state-pages', count( $pagesWithProposedState ) );
568 $out->addHTML( $this->displayPagesWithProposedState( $pagesWithProposedState ) );
569 }
570 }
571
572 $pages = $types['broken'];
573 if ( $pages ) {
574 $out->wrapWikiMsg( '== $1 ==', 'tpt-other-pages-title' );
575 $out->addWikiMsg( 'tpt-other-pages', count( $pages ) );
576 $out->addHTML( $this->getPageList( $pages, 'broken' ) );
577 }
578
579 $pages = $types['outdated'];
580 if ( $pages ) {
581 $out->wrapWikiMsg( '== $1 ==', 'tpt-outdated-pages-title' );
582 $out->addWikiMsg( 'tpt-outdated-pages', count( $pages ) );
583 $out->addHTML( $this->getPageList( $pages, 'outdated' ) );
584 }
585
586 $pages = $types['active'];
587 if ( $pages ) {
588 $out->wrapWikiMsg( '== $1 ==', 'tpt-old-pages-title' );
589 $out->addWikiMsg( 'tpt-old-pages', count( $pages ) );
590 $out->addHTML( $this->getPageList( $pages, 'active' ) );
591 }
592 }
593
594 private function actionLinks( array $page, string $type ): string {
595 // Performance optimization to avoid calling $this->msg in a loop
596 static $messageCache = null;
597 if ( $messageCache === null ) {
598 $messageCache = [
599 'mark' => $this->msg( 'tpt-rev-mark' )->text(),
600 'mark-tooltip' => $this->msg( 'tpt-rev-mark-tooltip' )->text(),
601 'encourage' => $this->msg( 'tpt-rev-encourage' )->text(),
602 'encourage-tooltip' => $this->msg( 'tpt-rev-encourage-tooltip' )->text(),
603 'discourage' => $this->msg( 'tpt-rev-discourage' )->text(),
604 'discourage-tooltip' => $this->msg( 'tpt-rev-discourage-tooltip' )->text(),
605 'unmark' => $this->msg( 'tpt-rev-unmark' )->text(),
606 'unmark-tooltip' => $this->msg( 'tpt-rev-unmark-tooltip' )->text(),
607 'pipe-separator' => $this->msg( 'pipe-separator' )->escaped(),
608 ];
609 }
610
611 $actions = [];
613 $title = $page['title'];
614 $user = $this->getUser();
615
616 // Class to allow one-click POSTs
617 $js = [ 'class' => 'mw-translate-jspost' ];
618
619 if ( $user->isAllowed( 'pagetranslation' ) ) {
620 // Enable re-marking of all pages to allow changing of priority languages
621 // or migration to the new syntax version
622 if ( $type !== 'broken' ) {
623 $actions[] = $this->getLinkRenderer()->makeKnownLink(
624 $this->getPageTitle(),
625 $messageCache['mark'],
626 [ 'title' => $messageCache['mark-tooltip'] ],
627 [
628 'do' => 'mark',
629 'target' => $title->getPrefixedText(),
630 'revision' => $title->getLatestRevID(),
631 ]
632 );
633 }
634
635 if ( $type !== 'proposed' ) {
636 if ( $page['discouraged'] ) {
637 $actions[] = $this->getLinkRenderer()->makeKnownLink(
638 $this->getPageTitle(),
639 $messageCache['encourage'],
640 [ 'title' => $messageCache['encourage-tooltip'] ] + $js,
641 [
642 'do' => 'encourage',
643 'target' => $title->getPrefixedText(),
644 'revision' => -1,
645 ]
646 );
647 } else {
648 $actions[] = $this->getLinkRenderer()->makeKnownLink(
649 $this->getPageTitle(),
650 $messageCache['discourage'],
651 [ 'title' => $messageCache['discourage-tooltip'] ] + $js,
652 [
653 'do' => 'discourage',
654 'target' => $title->getPrefixedText(),
655 'revision' => -1,
656 ]
657 );
658 }
659
660 $actions[] = $this->getLinkRenderer()->makeKnownLink(
661 $this->getPageTitle(),
662 $messageCache['unmark'],
663 [ 'title' => $messageCache['unmark-tooltip'] ],
664 [
665 'do' => $type === 'broken' ? 'unmark' : 'unlink',
666 'target' => $title->getPrefixedText(),
667 'revision' => -1,
668 ]
669 );
670 }
671 }
672
673 if ( !$actions ) {
674 return '';
675 }
676
677 return '<div>' . implode( $messageCache['pipe-separator'], $actions ) . '</div>';
678 }
679
680 private function showPage( TranslatablePageMarkOperation $operation ): void {
681 $page = $operation->getPage();
682 $out = $this->getOutput();
683 $out->addBacklinkSubtitle( $page->getTitle() );
684 $out->addWikiMsg( 'tpt-showpage-intro' );
685
686 $this->addPageForm(
687 $page->getTitle(),
688 'mw-tpt-sp-markform',
689 'mark',
690 $page->getRevision()
691 );
692
693 $out->wrapWikiMsg( '==$1==', 'tpt-sections-oldnew' );
694
695 $diffOld = $this->msg( 'tpt-diff-old' )->escaped();
696 $diffNew = $this->msg( 'tpt-diff-new' )->escaped();
697 $hasChanges = false;
698
699 // Check whether page title was previously marked for translation.
700 // If the page is marked for translation the first time, default to checked,
701 // unless the page is a template. T305240
702 $defaultChecked = (
703 $operation->isFirstMark() &&
704 !$page->getTitle()->inNamespace( NS_TEMPLATE )
705 ) || $page->hasPageDisplayTitle();
706
707 $sourceLanguage = $this->languageFactory->getLanguage( $page->getSourceLanguageCode() );
708
709 foreach ( $operation->getUnits() as $s ) {
710 if ( $s->id === TranslatablePage::DISPLAY_TITLE_UNIT_ID ) {
711 // Set section type as new if title previously unchecked
712 $s->type = $defaultChecked ? $s->type : 'new';
713
714 // Checkbox for page title optional translation
715 $checkBox = new FieldLayout(
716 new CheckboxInputWidget( [
717 'name' => 'translatetitle',
718 'selected' => $defaultChecked,
719 ] ),
720 [
721 'label' => $this->msg( 'tpt-translate-title' )->text(),
722 'align' => 'inline',
723 'classes' => [ 'mw-tpt-m-vertical' ]
724 ]
725 );
726 $out->addHTML( $checkBox->toString() );
727 }
728
729 if ( $s->type === 'new' ) {
730 $hasChanges = true;
731 $name = $this->msg( 'tpt-section-new', $s->id )->escaped();
732 } else {
733 $name = $this->msg( 'tpt-section', $s->id )->escaped();
734 }
735
736 if ( $s->type === 'changed' ) {
737 $hasChanges = true;
738 $diff = new DifferenceEngine();
739 $diff->setTextLanguage( $sourceLanguage );
740 $diff->setReducedLineNumbers();
741
742 $oldContent = ContentHandler::makeContent( $s->getOldText(), $diff->getTitle() );
743 $newContent = ContentHandler::makeContent( $s->getText(), $diff->getTitle() );
744
745 $diff->setContent( $oldContent, $newContent );
746
747 $text = $diff->getDiff( $diffOld, $diffNew );
748 $diffOld = $diffNew = null;
749 $diff->showDiffStyle();
750
751 $id = "tpt-sect-{$s->id}-action-nofuzzy";
752 $checkLabel = new FieldLayout(
753 new CheckboxInputWidget( [
754 'name' => $id,
755 'selected' => $s->onlyTvarsChanged()
756 ] ),
757 [
758 'label' => $this->msg( 'tpt-action-nofuzzy' )->text(),
759 'align' => 'inline',
760 'classes' => [ 'mw-tpt-m-vertical' ]
761 ]
762 );
763 $text = $checkLabel->toString() . $text;
764 } else {
765 $text = Utilities::convertWhiteSpaceToHTML( $s->getText() );
766 }
767
768 # For changed text, the language is set by $diff->setTextLanguage()
769 $lang = $s->type === 'changed' ? null : $sourceLanguage;
770 $out->addHTML( MessageWebImporter::makeSectionElement(
771 $name,
772 $s->type,
773 $text,
774 $lang
775 ) );
776
777 foreach ( $s->getIssues() as $issue ) {
778 $severity = $issue->getSeverity();
779 if ( $severity === TranslationUnitIssue::WARNING ) {
780 $box = Html::warningBox( $this->msg( $issue )->escaped() );
781 } elseif ( $severity === TranslationUnitIssue::ERROR ) {
782 $box = Html::errorBox( $this->msg( $issue )->escaped() );
783 } else {
784 throw new UnexpectedValueException(
785 "Unknown severity: $severity for key: {$issue->getKey()}"
786 );
787 }
788
789 $out->addHTML( $box );
790 }
791 }
792
793 if ( $operation->getDeletedUnits() ) {
794 $hasChanges = true;
795 $out->wrapWikiMsg( '==$1==', 'tpt-sections-deleted' );
796
797 foreach ( $operation->getDeletedUnits() as $s ) {
798 $name = $this->msg( 'tpt-section-deleted', $s->id )->escaped();
799 $text = Utilities::convertWhiteSpaceToHTML( $s->getText() );
800 $out->addHTML( MessageWebImporter::makeSectionElement(
801 $name,
802 'deleted',
803 $text,
804 $sourceLanguage
805 ) );
806 }
807 }
808
809 // Display template changes if applicable
810 $markedTag = $page->getMarkedTag();
811 if ( $markedTag !== null ) {
812 $hasChanges = true;
813 $newTemplate = $operation->getParserOutput()->sourcePageTemplateForDiffs();
814 $oldPage = TranslatablePage::newFromRevision(
815 $page->getTitle(),
816 $markedTag
817 );
818 $oldTemplate = $this->translatablePageParser
819 ->parse( $oldPage->getText() )
820 ->sourcePageTemplateForDiffs();
821
822 if ( $oldTemplate !== $newTemplate ) {
823 $out->wrapWikiMsg( '==$1==', 'tpt-sections-template' );
824
825 $diff = new DifferenceEngine();
826 $diff->setTextLanguage( $sourceLanguage );
827
828 $oldContent = ContentHandler::makeContent( $oldTemplate, $diff->getTitle() );
829 $newContent = ContentHandler::makeContent( $newTemplate, $diff->getTitle() );
830
831 $diff->setContent( $oldContent, $newContent );
832
833 $text = $diff->getDiff(
834 $this->msg( 'tpt-diff-old' )->escaped(),
835 $this->msg( 'tpt-diff-new' )->escaped()
836 );
837 $diff->showDiffStyle();
838 $diff->setReducedLineNumbers();
839
840 $out->addHTML( Xml::tags( 'div', [], $text ) );
841 }
842 }
843
844 if ( !$hasChanges ) {
845 $out->wrapWikiMsg( Html::successBox( '$1' ), 'tpt-mark-nochanges' );
846 }
847
848 $this->priorityLanguagesForm( $page );
849
850 // If an existing page does not have the supportsTransclusion flag, keep the checkbox unchecked,
851 // If the page is being marked for translation for the first time, the checkbox can be checked
852 $this->templateTransclusionForm( $page->supportsTransclusion() ?? $operation->isFirstMark() );
853
854 $version = $this->messageGroupMetadata->getWithDefaultValue(
855 $page->getMessageGroupId(), 'version', TranslatablePageMarker::DEFAULT_SYNTAX_VERSION
856 );
857 $this->syntaxVersionForm( $version, $operation->isFirstMark() );
858
859 $submitButton = new FieldLayout(
860 new ButtonInputWidget( [
861 'label' => $this->msg( 'tpt-submit' )->text(),
862 'type' => 'submit',
863 'flags' => [ 'primary', 'progressive' ],
864 ] ),
865 [
866 'label' => null,
867 'align' => 'top',
868 ]
869 );
870
871 $out->addHTML( $submitButton->toString() );
872 $out->addHTML( '</form>' );
873 }
874
875 private function priorityLanguagesForm( TranslatablePage $page ): void {
876 $groupId = $page->getMessageGroupId();
877 $interfaceLanguage = $this->getLanguage()->getCode();
878 $storedLanguages = (string)$this->messageGroupMetadata->get( $groupId, 'prioritylangs' );
879 $default = $storedLanguages !== '' ? explode( ',', $storedLanguages ) : [];
880
881 $priorityReason = $this->messageGroupMetadata->get( $groupId, 'priorityreason' );
882 $priorityReason = $priorityReason !== false ? $priorityReason : '';
883
884 $form = new FieldsetLayout( [
885 'items' => [
886 new FieldLayout(
887 new LanguagesMultiselectWidget( [
888 'infusable' => true,
889 'name' => 'prioritylangs',
890 'id' => 'mw-translate-SpecialPageTranslation-prioritylangs',
891 'languages' => Utilities::getLanguageNames( $interfaceLanguage ),
892 'default' => $default,
893 ] ),
894 [
895 'label' => $this->msg( 'tpt-select-prioritylangs' )->text(),
896 'align' => 'top',
897 ]
898 ),
899 new FieldLayout(
900 new CheckboxInputWidget( [
901 'name' => 'forcelimit',
902 'selected' => $this->messageGroupMetadata->get( $groupId, 'priorityforce' ) === 'on',
903 ] ),
904 [
905 'label' => $this->msg( 'tpt-select-prioritylangs-force' )->text(),
906 'align' => 'inline',
907 'help' => new HtmlSnippet( $this->msg( 'tpt-select-no-prioritylangs-force' )->parse() ),
908 ]
909 ),
910 new FieldLayout(
911 new TextInputWidget( [
912 'name' => 'priorityreason',
913 'value' => $priorityReason
914 ] ),
915 [
916 'label' => $this->msg( 'tpt-select-prioritylangs-reason' )->text(),
917 'align' => 'top',
918 ]
919 ),
920
921 ],
922 ] );
923
924 $this->getOutput()->wrapWikiMsg( '==$1==', 'tpt-sections-prioritylangs' );
925 $this->getOutput()->addHTML( $form->toString() );
926 }
927
928 private function syntaxVersionForm( string $version, bool $firstMark ): void {
929 $out = $this->getOutput();
930
931 if ( $version === TranslatablePageMarker::LATEST_SYNTAX_VERSION || $firstMark ) {
932 return;
933 }
934
935 $out->wrapWikiMsg( '==$1==', 'tpt-sections-syntaxversion' );
936 $out->addWikiMsg(
937 'tpt-syntaxversion-text',
938 '<code>' . wfEscapeWikiText( '<span lang="en" dir="ltr">...</span>' ) . '</code>',
939 '<code>' . wfEscapeWikiText( '<translate nowrap>...</translate>' ) . '</code>'
940 );
941
942 $checkBox = new FieldLayout(
943 new CheckboxInputWidget( [
944 'name' => 'use-latest-syntax'
945 ] ),
946 [
947 'label' => $out->msg( 'tpt-syntaxversion-label' )->text(),
948 'align' => 'inline',
949 ]
950 );
951
952 $out->addHTML( $checkBox->toString() );
953 }
954
955 private function templateTransclusionForm( bool $supportsTransclusion ): void {
956 $out = $this->getOutput();
957 $out->wrapWikiMsg( '==$1==', 'tpt-transclusion' );
958
959 $checkBox = new FieldLayout(
960 new CheckboxInputWidget( [
961 'name' => 'transclusion',
962 'selected' => $supportsTransclusion
963 ] ),
964 [
965 'label' => $out->msg( 'tpt-transclusion-label' )->text(),
966 'align' => 'inline',
967 ]
968 );
969
970 $out->addHTML( $checkBox->toString() );
971 }
972
973 private function getPriorityLanguage( WebRequest $request ): array {
974 // Get the priority languages from the request
975 // We've to do some extra work here because if JS is disabled, we will be getting
976 // the values split by newline.
977 $priorityLanguages = rtrim( trim( $request->getVal( 'prioritylangs', '' ) ), ',' );
978 $priorityLanguages = str_replace( "\n", ',', $priorityLanguages );
979 $priorityLanguages = array_map( 'trim', explode( ',', $priorityLanguages ) );
980 $priorityLanguages = array_unique( array_filter( $priorityLanguages ) );
981
982 $forcePriorityLanguage = $request->getCheck( 'forcelimit' );
983 $priorityLanguageReason = trim( $request->getText( 'priorityreason' ) );
984
985 return [ $priorityLanguages, $forcePriorityLanguage, $priorityLanguageReason ];
986 }
987
988 private function getPageList( array $pages, string $type ): string {
989 $items = [];
990 $tagsTextCache = [];
991
992 $tagDiscouraged = $this->msg( 'tpt-tag-discouraged' )->escaped();
993 $tagOldSyntax = $this->msg( 'tpt-tag-oldsyntax' )->escaped();
994 $tagNoTransclusionSupport = $this->msg( 'tpt-tag-no-transclusion-support' )->escaped();
995
996 foreach ( $pages as $page ) {
997 $link = $this->getLinkRenderer()->makeKnownLink( $page['title'] );
998 $acts = $this->actionLinks( $page, $type );
999 $tags = [];
1000 if ( $page['discouraged'] ) {
1001 $tags[] = $tagDiscouraged;
1002 }
1003 if ( $type !== 'proposed' ) {
1004 if ( $page['version'] !== TranslatablePageMarker::LATEST_SYNTAX_VERSION ) {
1005 $tags[] = $tagOldSyntax;
1006 }
1007
1008 if ( $page['transclusion'] !== '1' ) {
1009 $tags[] = $tagNoTransclusionSupport;
1010 }
1011 }
1012
1013 $tagList = '';
1014 if ( $tags ) {
1015 // Performance optimization to avoid calling $this->msg in a loop
1016 $tagsKey = implode( '', $tags );
1017 $tagsTextCache[$tagsKey] ??= $this->msg( 'parentheses' )
1018 ->rawParams( $this->getLanguage()->pipeList( $tags ) )
1019 ->escaped();
1020
1021 $tagList = Html::rawElement(
1022 'span',
1023 [ 'class' => 'mw-tpt-actions' ],
1024 $tagsTextCache[$tagsKey]
1025 );
1026 }
1027
1028 $items[] = "<li class='mw-tpt-pagelist-item'>$link $tagList $acts</li>";
1029 }
1030
1031 return '<ol>' . implode( '', $items ) . '</ol>';
1032 }
1033
1035 private function displayPagesWithProposedState( array $pagesWithProposedState ): string {
1036 $items = [];
1037 $preparePageAction = $this->msg( 'tpt-prepare-page' )->text();
1038 $preparePageTooltip = $this->msg( 'tpt-prepare-page-tooltip' )->text();
1039 $linkRenderer = $this->getLinkRenderer();
1040 foreach ( $pagesWithProposedState as $pageRecord ) {
1041 $link = $linkRenderer->makeKnownLink( $pageRecord );
1042 $action = $linkRenderer->makeKnownLink(
1043 SpecialPage::getTitleFor( 'PagePreparation' ),
1044 $preparePageAction,
1045 [ 'title' => $preparePageTooltip ],
1046 [ 'page' => ( Title::newFromPageReference( $pageRecord ) )->getPrefixedText() ]
1047 );
1048 $items[] = "<li class='mw-tpt-pagelist-item'>$link <div>$action</div></li>";
1049 }
1050 return '<ol>' . implode( '', $items ) . '</ol>';
1051 }
1052
1053 private function showTranslationSettings( Title $target, ?ErrorPageError $block ): void {
1054 $out = $this->getOutput();
1055 $out->setPageTitle( $this->msg( 'tpt-translation-settings-page-title' )->text() );
1056 $out->addBacklinkSubtitle( $target );
1057
1058 $currentState = $this->translatablePageStateStore->get( $target );
1059
1060 if ( !$this->translatablePageView->canManageTranslationSettings( $target, $this->getUser() ) ) {
1061 $out->wrapWikiMsg( Html::errorBox( '$1' ), 'tpt-translation-settings-restricted' );
1062 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
1063 return;
1064 }
1065
1066 if ( $block ) {
1067 $out->wrapWikiMsg( Html::errorBox( '$1' ), $block->getMessageObject() );
1068 }
1069
1070 if ( $currentState ) {
1071 $this->displayStateInfoMessage( $target, $currentState );
1072 }
1073
1074 $this->addPageForm( $target, 'mw-tpt-sp-settings', 'settings', null );
1075 $out->addHTML(
1076 Html::rawElement(
1077 'p',
1078 [ 'class' => 'mw-tpt-vm' ],
1079 Html::element( 'strong', [], $this->msg( 'tpt-translation-settings-subtitle' ) )
1080 )
1081 );
1082
1083 $currentStateId = $currentState ? $currentState->getStateId() : null;
1084 $options = new FieldsetLayout( [
1085 'items' => [
1086 new FieldLayout(
1087 new RadioInputWidget( [
1088 'name' => 'translatable-page-state',
1089 'value' => 'ignored',
1090 'selected' => $currentStateId === TranslatableBundleState::IGNORE
1091 ] ),
1092 [
1093 'label' => $this->msg( 'tpt-translation-settings-ignore' )->text(),
1094 'align' => 'inline',
1095 'help' => $this->msg( 'tpt-translation-settings-ignore-hint' )->text(),
1096 'helpInline' => true,
1097 ]
1098 ),
1099 new FieldLayout(
1100 new RadioInputWidget( [
1101 'name' => 'translatable-page-state',
1102 'value' => 'unstable',
1103 'selected' => $currentStateId === null
1104 ] ),
1105 [
1106 'label' => $this->msg( 'tpt-translation-settings-unstable' )->text(),
1107 'align' => 'inline',
1108 'help' => $this->msg( 'tpt-translation-settings-unstable-hint' )->text(),
1109 'helpInline' => true,
1110 ]
1111 ),
1112 new FieldLayout(
1113 new RadioInputWidget( [
1114 'name' => 'translatable-page-state',
1115 'value' => 'proposed',
1116 'selected' => $currentStateId === TranslatableBundleState::PROPOSE
1117 ] ),
1118 [
1119 'label' => $this->msg( 'tpt-translation-settings-propose' )->text(),
1120 'align' => 'inline',
1121 'help' => $this->msg( 'tpt-translation-settings-propose-hint' )->text(),
1122 'helpInline' => true,
1123 ]
1124 ),
1125 ],
1126 ] );
1127
1128 $out->addHTML( $options->toString() );
1129
1130 $submitButton = new FieldLayout(
1131 new ButtonInputWidget( [
1132 'label' => $this->msg( 'tpt-translation-settings-save' )->text(),
1133 'type' => 'submit',
1134 'flags' => [ 'primary', 'progressive' ],
1135 'disabled' => $block !== null,
1136 ] )
1137 );
1138
1139 $out->addHTML( $submitButton->toString() );
1140 $out->addHTML( Html::closeElement( 'form' ) );
1141 }
1142
1143 private function handleTranslationState( Title $title, string $selectedState ): void {
1144 $validStateValues = [ 'ignored', 'unstable', 'proposed' ];
1145 $out = $this->getOutput();
1146 if ( !in_array( $selectedState, $validStateValues ) ) {
1147 throw new InvalidArgumentException( "Invalid translation state selected: $selectedState" );
1148 }
1149
1150 $user = $this->getUser();
1151 if ( !$this->translatablePageView->canManageTranslationSettings( $title, $user ) ) {
1152 $this->showTranslationStateRestricted();
1153 return;
1154 }
1155
1156 $bundleState = TranslatableBundleState::newFromText( $selectedState );
1157 if ( $selectedState === 'unstable' ) {
1158 $this->translatablePageStateStore->remove( $title );
1159 } else {
1160 $this->translatablePageStateStore->set( $title, $bundleState );
1161 }
1162
1163 $this->displayStateInfoMessage( $title, $bundleState );
1164 $out->setPageTitle( $this->msg( 'tpt-translation-settings-page-title' )->text() );
1165 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
1166 }
1167
1168 private function addPageForm(
1169 Title $target,
1170 string $formClass,
1171 string $action,
1172 ?int $revision
1173 ): void {
1174 $formParams = [
1175 'method' => 'post',
1176 'action' => $this->getPageTitle()->getLocalURL(),
1177 'class' => $formClass
1178 ];
1179
1180 $this->getOutput()->addHTML(
1181 Xml::openElement( 'form', $formParams ) .
1182 Html::hidden( 'do', $action ) .
1183 Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
1184 ( $revision ? Html::hidden( 'revision', $revision ) : '' ) .
1185 Html::hidden( 'target', $target->getPrefixedText() ) .
1186 Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() )
1187 );
1188 }
1189
1190 private function displayStateInfoMessage( Title $title, TranslatableBundleState $bundleState ): void {
1191 $stateId = $bundleState->getStateId();
1192 if ( $stateId === TranslatableBundleState::UNSTABLE ) {
1193 $infoMessage = $this->msg( 'tpt-translation-settings-unstable-notice' );
1194 } elseif ( $stateId === TranslatableBundleState::PROPOSE ) {
1195 $userHasPageTranslationRight = $this->getUser()->isAllowed( 'pagetranslation' );
1196 if ( $userHasPageTranslationRight ) {
1197 $infoMessage = $this->msg( 'tpt-translation-settings-proposed-pagetranslation-notice' )->params(
1198 'https://www.mediawiki.org/wiki/Special:MyLanguage/' .
1199 'Help:Extension:Translate/Page_translation_administration',
1200 $title->getFullURL( 'action=edit' ),
1201 SpecialPage::getTitleFor( 'PagePreparation' )
1202 ->getFullURL( [ 'page' => $title->getPrefixedText() ] )
1203 );
1204 } else {
1205 $infoMessage = $this->msg( 'tpt-translation-settings-proposed-editor-notice' );
1206 }
1207 } else {
1208 $infoMessage = $this->msg( 'tpt-translation-settings-ignored-notice' );
1209 }
1210
1211 $this->getOutput()->wrapWikiMsg( Html::noticeBox( '$1', '' ), $infoMessage );
1212 }
1213
1214 private function getBlock( WebRequest $request, User $user, Title $title ): ?ErrorPageError {
1215 if ( $this->permissionManager->isBlockedFrom( $user, $title, !$request->wasPosted() ) ) {
1216 $block = $user->getBlock();
1217 if ( $block ) {
1218 return new UserBlockedError(
1219 $block,
1220 $user,
1221 $this->getLanguage(),
1222 $request->getIP()
1223 );
1224 }
1225
1226 return new PermissionsError( 'pagetranslation', [ 'badaccess-group0' ] );
1227 }
1228
1229 return null;
1230 }
1231
1232 private function showTranslationStateRestricted(): void {
1233 $out = $this->getOutput();
1234 $out->wrapWikiMsg( Html::errorBox( "$1" ), 'tpt-translation-settings-restricted' );
1235 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
1236 }
1237}
Factory class for accessing message groups individually by id or all of them as a list.
Class to manage revision tags for translatable bundles.
Stores and validates possible translation states for translatable bundles.
Offers functionality for reading and updating Translate group related metadata.
A special page for marking revisions of pages for translation.
static buildPageArray(IResultWrapper $res)
TODO: Move this function to SyncTranslatableBundleStatusMaintenanceScript once we start using the tra...
static loadPagesFromDB()
TODO: Move this function to SyncTranslatableBundleStatusMaintenanceScript once we start using the tra...
Exception thrown when TranslatablePageMarker is unable to unmark a page for translation.
Service to mark/unmark pages from translation and perform related validations.
Generates ParserOutput from text or removes all tags from a text.
Value object containing user configurable settings when marking a page for translation.
Logic and code to generate various aspects related to how translatable pages are displayed.
Mixed bag of methods related to translatable pages.
static newFromTitle(PageIdentity $title)
Constructs a translatable page from title.
static getMessageGroupIdFromTitle(PageReference $page)
Constructs MessageGroup id for any title.
Essentially random collection of helper functions, similar to GlobalFunctions.php.
Definition Utilities.php:31