Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
PageTranslationSpecialPage.php
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\PageTranslation;
5
6use InvalidArgumentException;
7use MediaWiki\Content\ContentHandler;
8use MediaWiki\Diff\DifferenceEngine;
9use MediaWiki\Exception\ErrorPageError;
10use MediaWiki\Exception\PermissionsError;
11use MediaWiki\Exception\UserBlockedError;
19use MediaWiki\Extension\TranslationNotifications\SpecialNotifyTranslators;
20use MediaWiki\Html\Html;
21use MediaWiki\JobQueue\JobQueueGroup;
22use MediaWiki\Language\FormatterFactory;
23use MediaWiki\Language\LanguageFactory;
24use MediaWiki\Logging\ManualLogEntry;
25use MediaWiki\Page\LinkBatchFactory;
26use MediaWiki\Page\PageRecord;
27use MediaWiki\Permissions\PermissionManager;
28use MediaWiki\Request\WebRequest;
29use MediaWiki\Revision\MutableRevisionRecord;
30use MediaWiki\Revision\SlotRecord;
31use MediaWiki\SpecialPage\SpecialPage;
32use MediaWiki\Status\StatusFormatter;
33use MediaWiki\Title\Title;
34use MediaWiki\User\User;
35use MediaWiki\Widget\LanguageSelectWidget;
36use MediaWiki\Widget\ToggleSwitchWidget;
37use OOUI\ButtonInputWidget;
38use OOUI\CheckboxInputWidget;
39use OOUI\DropdownInputWidget;
40use OOUI\FieldLayout;
41use OOUI\FieldsetLayout;
42use OOUI\HtmlSnippet;
43use OOUI\RadioInputWidget;
44use OOUI\TextInputWidget;
45use UnexpectedValueException;
46use Wikimedia\Rdbms\IDBAccessObject;
47use Wikimedia\Rdbms\IResultWrapper;
48use function count;
49use function wfEscapeWikiText;
50
62class PageTranslationSpecialPage extends SpecialPage {
63 private const DISPLAY_STATUS_MAPPING = [
64 TranslatablePageStatus::PROPOSED => 'proposed',
65 TranslatablePageStatus::ACTIVE => 'active',
66 TranslatablePageStatus::OUTDATED => 'outdated',
67 TranslatablePageStatus::BROKEN => 'broken'
68 ];
69 private LanguageFactory $languageFactory;
70 private LinkBatchFactory $linkBatchFactory;
71 private JobQueueGroup $jobQueueGroup;
72 private PermissionManager $permissionManager;
73 private TranslatablePageMarker $translatablePageMarker;
74 private TranslatablePageParser $translatablePageParser;
75 private MessageGroupMetadata $messageGroupMetadata;
76 private TranslatablePageView $translatablePageView;
77 private TranslatablePageStateStore $translatablePageStateStore;
78 private StatusFormatter $statusFormatter;
79
80 public function __construct(
81 LanguageFactory $languageFactory,
82 LinkBatchFactory $linkBatchFactory,
83 JobQueueGroup $jobQueueGroup,
84 PermissionManager $permissionManager,
85 TranslatablePageMarker $translatablePageMarker,
86 TranslatablePageParser $translatablePageParser,
87 MessageGroupMetadata $messageGroupMetadata,
88 TranslatablePageView $translatablePageView,
89 TranslatablePageStateStore $translatablePageStateStore,
90 FormatterFactory $formatterFactory
91 ) {
92 parent::__construct( 'PageTranslation' );
93 $this->languageFactory = $languageFactory;
94 $this->linkBatchFactory = $linkBatchFactory;
95 $this->jobQueueGroup = $jobQueueGroup;
96 $this->permissionManager = $permissionManager;
97 $this->translatablePageMarker = $translatablePageMarker;
98 $this->translatablePageParser = $translatablePageParser;
99 $this->messageGroupMetadata = $messageGroupMetadata;
100 $this->translatablePageView = $translatablePageView;
101 $this->translatablePageStateStore = $translatablePageStateStore;
102 $this->statusFormatter = $formatterFactory->getStatusFormatter( $this );
103 }
104
106 public function doesWrites(): bool {
107 return true;
108 }
109
110 protected function getGroupName(): string {
111 return 'translation';
112 }
113
115 public function execute( $parameters ) {
116 $this->setHeaders();
117
118 $user = $this->getUser();
119 $request = $this->getRequest();
120
121 $target = $request->getText( 'target', $parameters ?? '' );
122 $revision = $request->getIntOrNull( 'revision' );
123 $action = $request->getVal( 'do' );
124 $out = $this->getOutput();
125 $out->addModules( 'ext.translate.special.pagetranslation' );
126 $out->addModuleStyles( [
127 'codex-styles',
128 'ext.translate.specialpages.styles',
129 'mediawiki.codex.messagebox.styles',
130 ] );
131 $out->addHelpLink( 'Help:Extension:Translate/Page_translation_example' );
132 $out->enableOOUI();
133
134 if ( $target === '' ) {
135 $this->listPages();
136
137 return;
138 }
139
140 $title = Title::newFromText( $target );
141 if ( !$title ) {
142 $out->wrapWikiMsg( Html::errorBox( '$1' ), [ 'tpt-badtitle', $target ] );
143 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
144
145 return;
146 }
147
148 $this->getSkin()->setRelevantTitle( $title );
149
150 if ( !$title->exists() ) {
151 $out->wrapWikiMsg(
152 Html::errorBox( '$1' ),
153 [ 'tpt-nosuchpage', $title->getPrefixedText() ]
154 );
155 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
156
157 return;
158 }
159
160 if ( $action === 'settings' && !$this->translatablePageView->isTranslationBannerNamespaceConfigured() ) {
161 $this->showTranslationStateRestricted();
162 return;
163 }
164
165 $block = $this->getBlock( $request, $user, $title );
166 if ( $action === 'settings' && !$request->wasPosted() ) {
167 $this->showTranslationSettings( $title, $block );
168 return;
169 }
170
171 if ( $block ) {
172 throw $block;
173 }
174
175 // Check token for all POST actions here
176 $csrfTokenSet = $this->getContext()->getCsrfTokenSet();
177 if ( $request->wasPosted() && !$csrfTokenSet->matchTokenField( 'token' ) ) {
178 throw new PermissionsError( 'pagetranslation' );
179 }
180
181 if ( $action === 'settings' && $request->wasPosted() ) {
182 $this->handleTranslationState( $title, $request->getRawVal( 'translatable-page-state' ) ?? '' );
183 return;
184 }
185
186 // Anything other than listing the pages or manipulating settings needs permissions
187 if ( !$user->isAllowed( 'pagetranslation' ) ) {
188 throw new PermissionsError( 'pagetranslation' );
189 }
190
191 if ( $action === 'mark' ) {
192 // Has separate form
193 $this->onActionMark( $title, $revision );
194
195 return;
196 }
197
198 // On GET requests, show form which has token
199 if ( !$request->wasPosted() ) {
200 $params = [
201 'do' => $action,
202 'target' => $title->getPrefixedText(),
203 'revision' => $revision,
204 ];
205 switch ( $action ) {
206 case 'unlink':
207 case 'unmark':
208 $this->showConfirmation( $params, 'tpt-unlink-button', 'cdx-button--action-destructive' );
209 break;
210 case 'discourage':
211 case 'encourage':
212 $this->showConfirmation( $params );
213 break;
214 default:
215 $out->wrapWikiMsg(
216 Html::errorBox( '$1' ),
217 [ 'tpt-nosuchaction', $action ]
218 );
219 }
220 return;
221 }
222
223 if ( $action === 'discourage' || $action === 'encourage' ) {
224 $id = TranslatablePage::getMessageGroupIdFromTitle( $title );
225 $current = MessageGroups::getPriority( $id );
226
227 if ( $action === 'encourage' ) {
228 $new = '';
229 } else {
230 $new = 'discouraged';
231 }
232
233 if ( $new !== $current ) {
234 MessageGroups::setPriority( $id, $new );
235 $entry = new ManualLogEntry( 'pagetranslation', $action );
236 $entry->setPerformer( $user );
237 $entry->setTarget( $title );
238 $logId = $entry->insert();
239 $entry->publish( $logId );
240 }
241
242 // Defer stats purging of parent aggregate groups. Shared groups can contain other
243 // groups as well, which we do not need to update. We could filter non-aggregate
244 // groups out, or use MessageGroups::getParentGroups, though it has an inconvenient
245 // return value format for this use case.
246 $group = MessageGroups::getGroup( $id );
247 if ( $group ) {
248 $sharedGroupIds = MessageGroups::getSharedGroups( $group );
249 if ( $sharedGroupIds !== [] ) {
250 $job = RebuildMessageGroupStatsJob::newRefreshGroupsJob( $sharedGroupIds );
251 $this->jobQueueGroup->push( $job );
252 }
253 }
254
255 // Show updated page with a notice
256 $this->listPages();
257
258 return;
259 }
260
261 if ( $action === 'unlink' || $action === 'unmark' ) {
262 try {
263 $this->translatablePageMarker->unmarkPage(
264 TranslatablePage::newFromTitle( $title ),
265 $user,
266 $this,
267 $action === 'unlink'
268 );
269
270 $out->wrapWikiMsg(
271 Html::successBox( '$1' ),
272 [ 'tpt-unmarked', $title->getPrefixedText() ]
273 );
274 } catch ( TranslatablePageMarkException $e ) {
275 $out->wrapWikiMsg(
276 Html::errorBox( '$1' ),
277 $e->getMessageObject()
278 );
279 }
280
281 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
282 return;
283 }
284 $out->wrapWikiMsg(
285 Html::errorBox( '$1' ),
286 [ 'tpt-nosuchaction', $action ]
287 );
288 }
289
290 protected function onActionMark( Title $title, ?int $revision ): void {
291 $request = $this->getRequest();
292 $out = $this->getOutput();
293 $translateTitle = $request->getCheck( 'translatetitle' );
294
295 try {
296 $operation = $this->translatablePageMarker->getMarkOperation(
297 $title->toPageRecord(
298 $request->wasPosted() ? IDBAccessObject::READ_LATEST : IDBAccessObject::READ_NORMAL
299 ),
300 $revision,
301 // This parameter does double-duty; it specifies both whether to validate that the title can be
302 // translated and tells the TranslateTitlePageTranslation hook what title translation settings the user
303 // requested on form load, no settings were requested (null), so the hook should have the ability to
304 // specify whether the checkbox defaults to checked or unchecked on the basis of the default state
305 // (whether it has pre-existing title translations, is a template, etc.)
306 // This also means the page display title translatability won't be validated if it isn't checked by
307 // default which may or may not be a good thing
308 $request->wasPosted() ? $translateTitle : null
309 );
310 } catch ( TranslatablePageMarkException $e ) {
311 $out->addHTML( Html::errorBox( $this->msg( $e->getMessageObject() )->parse() ) );
312 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
313 return;
314 }
315
316 $unitNameValidationResult = $operation->getUnitValidationStatus();
317 // Non-fatal error which prevents saving
318 if ( $unitNameValidationResult->isOK() && $request->wasPosted() ) {
319 // Fetch priority language related information
320 [ $priorityLanguages, $forcePriorityLanguage, $priorityLanguageReason ] =
321 $this->getPriorityLanguage( $this->getRequest() );
322
323 $unitFuzzySelector = $request->getRawVal( 'unit-fuzzy-selector' );
324 if ( $unitFuzzySelector === 'all' ) {
325 $noFuzzyUnits = [];
326 } else {
327 // Get IDs of all changed units
328 $allChangedUnits = array_map(
329 static fn ( $unit ) => $unit->id,
330 array_filter(
331 $operation->getUnits(),
332 static fn ( $unit ) => $unit->type === 'changed'
333 )
334 );
335
336 if ( $unitFuzzySelector === 'none' ) {
337 $noFuzzyUnits = $allChangedUnits;
338 } else { // custom
339 $fuzzyUnits = $request->getArray( 'tpt-sect-fuzzy' ) ?? [];
340 // Filter the units that should not be fuzzied
341 $noFuzzyUnits = array_filter(
342 $allChangedUnits,
343 static fn ( $value ) => !in_array( $value, $fuzzyUnits )
344 );
345 }
346 }
347
348 $translatablePageSettings = new TranslatablePageSettings(
349 $priorityLanguages,
350 $forcePriorityLanguage,
351 $priorityLanguageReason,
352 $noFuzzyUnits,
353 $translateTitle,
354 $request->getCheck( 'use-latest-syntax' ),
355 $request->getCheck( 'transclusion' )
356 );
357
358 try {
359 $unitCount = $this->translatablePageMarker->markForTranslation(
360 $operation,
361 $translatablePageSettings,
362 $this,
363 $this->getUser()
364 );
365 $this->showSuccess( $operation->getPage(), $operation->isFirstMark(), $unitCount );
366 } catch ( TranslatablePageMarkException $e ) {
367 $out->wrapWikiMsg(
368 Html::errorBox( '$1' ),
369 $e->getMessageObject()
370 );
371 }
372 } else {
373 if ( !$unitNameValidationResult->isOK() ) {
374 $out->addHTML(
375 Html::errorBox( $this->statusFormatter->getHTML( $unitNameValidationResult ) )
376 );
377 }
378
379 $this->showPage( $operation );
380 }
381 }
382
390 private function showSuccess( TranslatablePage $page, bool $firstMark, int $unitCount ): void {
391 $titleText = $page->getTitle()->getPrefixedText();
392 $num = $this->getLanguage()->formatNum( $unitCount );
393 $link = SpecialPage::getTitleFor( 'Translate' )->getFullURL( [
394 'group' => $page->getMessageGroupId(),
395 'action' => 'page',
396 'filter' => '',
397 ] );
398
399 $this->getOutput()->wrapWikiMsg(
400 Html::successBox( '$1' ),
401 [ 'tpt-saveok', $titleText, $num, $link ]
402 );
403
404 // If the page is being marked for translation for the first time
405 // add a link to Special:PageMigration.
406 if ( $firstMark ) {
407 $this->getOutput()->addWikiMsg( 'tpt-saveok-first' );
408 }
409
410 // If TranslationNotifications is installed, and the user can notify
411 // translators, add a convenience link.
412 if ( method_exists( SpecialNotifyTranslators::class, 'execute' ) &&
413 $this->getUser()->isAllowed( SpecialNotifyTranslators::$right )
414 ) {
415 $link = SpecialPage::getTitleFor( 'NotifyTranslators' )->getFullURL(
416 [ 'tpage' => $page->getPageIdentity()->getId() ]
417 );
418 $this->getOutput()->addWikiMsg( 'tpt-offer-notify', $link );
419 }
420
421 $this->getOutput()->addWikiMsg( 'tpt-list-pages-in-translations' );
422 }
423
424 private function showConfirmation(
425 array $params,
426 string $buttonKey = 'tpt-generic-button',
427 string $buttonClass = 'cdx-button--action-progressive',
428 ): void {
429 $formParams = [
430 'method' => 'post',
431 'action' => $this->getPageTitle()->getLocalURL(),
432 ];
433
434 $params['title'] = $this->getPageTitle()->getPrefixedText();
435 $params['token'] = $this->getContext()->getCsrfTokenSet()->getToken();
436
437 $hidden = '';
438 foreach ( $params as $key => $value ) {
439 $hidden .= Html::hidden( $key, $value );
440 }
441 $action = $params['do'];
442
443 $this->getOutput()->addHTML(
444 Html::openElement( 'form', $formParams ) .
445 $hidden .
446 // tpt-discourage-confirm tpt-encourage-confirm tpt-unlink-confirm tpt-unmark-confirm
447 $this->msg( "tpt-$action-confirm" )->params( $params[ 'target' ] )->parseAsBlock() .
448 Html::element(
449 'button',
450 [
451 'type' => 'submit',
452 'class' => "cdx-button cdx-button--weight-primary $buttonClass",
453 ],
454 $this->msg( $buttonKey )->text()
455 ) .
456 Html::closeElement( 'form' )
457 );
458 }
459
464 public static function loadPagesFromDB(): IResultWrapper {
465 $dbr = Utilities::getSafeReadDB();
466 return $dbr->newSelectQueryBuilder()
467 ->select( [
468 'page_id',
469 'page_namespace',
470 'page_title',
471 'page_latest',
472 'rt_revision' => 'MAX(rt_revision)',
473 'rt_type'
474 ] )
475 ->from( 'page' )
476 ->join( 'revtag', null, 'page_id=rt_page' )
477 ->where( [
478 'rt_type' => [ RevTagStore::TP_MARK_TAG, RevTagStore::TP_READY_TAG ],
479 ] )
480 ->orderBy( [ 'page_namespace', 'page_title' ] )
481 ->groupBy( [ 'page_id', 'page_namespace', 'page_title', 'page_latest', 'rt_type' ] )
482 ->caller( __METHOD__ )
483 ->fetchResultSet();
484 }
485
490 public static function buildPageArray( IResultWrapper $res ): array {
491 $pages = [];
492 foreach ( $res as $r ) {
493 // We have multiple rows for same page, because of different tags
494 if ( !isset( $pages[$r->page_id] ) ) {
495 $pages[$r->page_id] = [];
496 $title = Title::newFromRow( $r );
497 $pages[$r->page_id]['title'] = $title;
498 $pages[$r->page_id]['latest'] = (int)$title->getLatestRevID();
499 }
500
501 $tag = $r->rt_type;
502 $pages[$r->page_id][$tag] = (int)$r->rt_revision;
503 }
504
505 return $pages;
506 }
507
514 private function classifyPages( array $pages ): array {
515 $out = [
516 // The ideal state for pages: marked and up to date
517 'active' => [],
518 'proposed' => [],
519 'outdated' => [],
520 'broken' => [],
521 ];
522
523 if ( $pages === [] ) {
524 return $out;
525 }
526
527 // Preload stuff for performance
528 $messageGroupIdsForPreload = [];
529 foreach ( $pages as $i => $page ) {
530 $id = TranslatablePage::getMessageGroupIdFromTitle( $page['title'] );
531 $messageGroupIdsForPreload[] = $id;
532 $pages[$i]['groupid'] = $id;
533 }
534 // Performance optimization: load only data we need to classify the pages
535 $metadata = $this->messageGroupMetadata->loadBasicMetadataForTranslatablePages(
536 $messageGroupIdsForPreload,
537 [ 'transclusion', 'version' ]
538 );
539
540 foreach ( $pages as $page ) {
541 $groupId = $page['groupid'];
542 $group = MessageGroups::getGroup( $groupId );
543
544 $page['discouraged'] = false;
545 if ( $group ) {
546 $page['discouraged'] = MessageGroups::getPriority( $group ) === 'discouraged';
547 }
548 $page['version'] = $metadata[$groupId]['version'] ?? TranslatablePageMarker::DEFAULT_SYNTAX_VERSION;
549 $page['transclusion'] = $metadata[$groupId]['transclusion'] ?? false;
550
551 // TODO: Eventually we should query the status directly from the TranslatableBundleStore
552 $tpStatus = TranslatablePage::determineStatus(
553 $page[RevTagStore::TP_READY_TAG] ?? null,
554 $page[RevTagStore::TP_MARK_TAG] ?? null,
555 $page['latest']
556 );
557
558 if ( !$tpStatus ) {
559 // Ignore pages for which status could not be determined.
560 continue;
561 }
562
563 $out[self::DISPLAY_STATUS_MAPPING[$tpStatus->getId()]][] = $page;
564 }
565
566 return $out;
567 }
568
569 public function listPages(): void {
570 $out = $this->getOutput();
571
572 $res = self::loadPagesFromDB();
573 $allPages = self::buildPageArray( $res );
574
575 $pagesWithProposedState = [];
576 if ( $this->translatablePageView->isTranslationBannerNamespaceConfigured() ) {
577 $pagesWithProposedState = $this->translatablePageStateStore->getRequested();
578 }
579
580 if ( !count( $allPages ) && !count( $pagesWithProposedState ) ) {
581 $out->addWikiMsg( 'tpt-list-nopages' );
582
583 return;
584 }
585
586 $lb = $this->linkBatchFactory->newLinkBatch();
587 $lb->setCaller( __METHOD__ );
588 foreach ( $allPages as $page ) {
589 $lb->addObj( $page['title'] );
590 }
591
592 foreach ( $pagesWithProposedState as $title ) {
593 $lb->addObj( $title );
594 }
595 $lb->execute();
596
597 $types = $this->classifyPages( $allPages );
598
599 $pages = $types['proposed'];
600 if ( $pages || $pagesWithProposedState ) {
601 $out->wrapWikiMsg( '== $1 ==', 'tpt-new-pages-title' );
602 if ( $pages ) {
603 $out->addWikiMsg( 'tpt-new-pages', count( $pages ) );
604 $out->addHTML( $this->getPageList( $pages, 'proposed' ) );
605 }
606
607 if ( $pagesWithProposedState ) {
608 $out->addWikiMsg( 'tpt-proposed-state-pages', count( $pagesWithProposedState ) );
609 $out->addHTML( $this->displayPagesWithProposedState( $pagesWithProposedState ) );
610 }
611 }
612
613 $pages = $types['broken'];
614 if ( $pages ) {
615 $out->wrapWikiMsg( '== $1 ==', 'tpt-other-pages-title' );
616 $out->addWikiMsg( 'tpt-other-pages', count( $pages ) );
617 $out->addHTML( $this->getPageList( $pages, 'broken' ) );
618 }
619
620 $pages = $types['outdated'];
621 if ( $pages ) {
622 $out->wrapWikiMsg( '== $1 ==', 'tpt-outdated-pages-title' );
623 $out->addWikiMsg( 'tpt-outdated-pages', count( $pages ) );
624 $out->addHTML( $this->getPageList( $pages, 'outdated' ) );
625 }
626
627 $pages = $types['active'];
628 if ( $pages ) {
629 $out->wrapWikiMsg( '== $1 ==', 'tpt-old-pages-title' );
630 $out->addWikiMsg( 'tpt-old-pages', count( $pages ) );
631 $out->addHTML( $this->getPageList( $pages, 'active' ) );
632 }
633 }
634
635 private function actionLinks( array $page, string $type ): string {
636 // Performance optimization to avoid calling $this->msg in a loop
637 static $messageCache = null;
638 if ( $messageCache === null ) {
639 $messageCache = [
640 'mark' => $this->msg( 'tpt-rev-mark' )->text(),
641 'mark-tooltip' => $this->msg( 'tpt-rev-mark-tooltip' )->text(),
642 'encourage' => $this->msg( 'tpt-rev-encourage' )->text(),
643 'encourage-tooltip' => $this->msg( 'tpt-rev-encourage-tooltip' )->text(),
644 'discourage' => $this->msg( 'tpt-rev-discourage' )->text(),
645 'discourage-tooltip' => $this->msg( 'tpt-rev-discourage-tooltip' )->text(),
646 'unmark' => $this->msg( 'tpt-rev-unmark' )->text(),
647 'unmark-tooltip' => $this->msg( 'tpt-rev-unmark-tooltip' )->text(),
648 'pipe-separator' => $this->msg( 'pipe-separator' )->escaped(),
649 ];
650 }
651
652 $actions = [];
654 $title = $page['title'];
655 $user = $this->getUser();
656
657 if ( $user->isAllowed( 'pagetranslation' ) ) {
658 // Enable re-marking of all pages to allow changing of priority languages
659 // or migration to the new syntax version
660 if ( $type !== 'broken' ) {
661 $actions[] = $this->getLinkRenderer()->makeKnownLink(
662 $this->getPageTitle(),
663 $messageCache['mark'],
664 [ 'title' => $messageCache['mark-tooltip'] ],
665 [
666 'do' => 'mark',
667 'target' => $title->getPrefixedText(),
668 'revision' => $title->getLatestRevID(),
669 ]
670 );
671 }
672
673 if ( $type !== 'proposed' ) {
674 if ( $page['discouraged'] ) {
675 $actions[] = $this->getLinkRenderer()->makeKnownLink(
676 $this->getPageTitle(),
677 $messageCache['encourage'],
678 [
679 'title' => $messageCache['encourage-tooltip'],
680 'class' => [ 'mw-translate-encourage' ],
681 ],
682 [
683 'do' => 'encourage',
684 'target' => $title->getPrefixedText(),
685 'revision' => -1,
686 ]
687 );
688 } else {
689 $actions[] = $this->getLinkRenderer()->makeKnownLink(
690 $this->getPageTitle(),
691 $messageCache['discourage'],
692 [
693 'title' => $messageCache['discourage-tooltip'],
694 'class' => [ 'mw-translate-discourage' ],
695 ],
696 [
697 'do' => 'discourage',
698 'target' => $title->getPrefixedText(),
699 'revision' => -1,
700 ]
701 );
702 }
703
704 $actions[] = $this->getLinkRenderer()->makeKnownLink(
705 $this->getPageTitle(),
706 $messageCache['unmark'],
707 [ 'title' => $messageCache['unmark-tooltip'] ],
708 [
709 'do' => $type === 'broken' ? 'unmark' : 'unlink',
710 'target' => $title->getPrefixedText(),
711 'revision' => -1,
712 ]
713 );
714 }
715 }
716
717 if ( !$actions ) {
718 return '';
719 }
720
721 return '<div>' . implode( $messageCache['pipe-separator'], $actions ) . '</div>';
722 }
723
724 private function showPage( TranslatablePageMarkOperation $operation ): void {
725 $page = $operation->getPage();
726 $out = $this->getOutput();
727 $out->addWikiMsg( 'tpt-showpage-intro' );
728
729 $this->addPageForm(
730 $page->getTitle(),
731 'mw-tpt-sp-markform mw-tpt-hide-unchanged',
732 'mark',
733 $page->getRevision()
734 );
735
736 $out->wrapWikiMsg( '==$1==', 'tpt-sections-oldnew' );
737
738 $diffOld = $this->msg( 'tpt-diff-old' )->escaped();
739 $diffNew = $this->msg( 'tpt-diff-new' )->escaped();
740 $hasChanges = false;
741
742 $sourceLanguage = $this->languageFactory->getLanguage( $page->getSourceLanguageCode() );
743
744 $hideUnchangedUnitToggle = '';
745 // Toggle for unchanged translation units
746 if ( array_filter(
747 $operation->getUnits(),
748 static fn ( $unit ) => $unit->type === 'old' && $unit->id !== TranslatablePage::DISPLAY_TITLE_UNIT_ID
749 ) ) {
750 $hideUnchangedUnitToggle = ( new FieldLayout(
751 new ToggleSwitchWidget( [
752 'name' => 'unchanged-translation-units',
753 'selected' => true
754 ] ),
755 [
756 'label' => $this->msg( 'tpt-translate-hide-unchanged-units' )->text(),
757 'align' => 'left',
758 ]
759 ) )->toString();
760 }
761
762 // Check if there are changed units
763 $requireUpdatesDropdown = '';
764 if ( array_filter(
765 $operation->getUnits(),
766 static fn ( $unit ) => $unit->type === 'changed'
767 ) ) {
768 $requireUpdatesDropdown = ( new FieldLayout(
769 new DropdownInputWidget( [
770 'name' => 'unit-fuzzy-selector',
771 'options' => [
772 [
773 'data' => 'all',
774 'label' => $this->msg( 'tpt-fuzzy-select-all' )->text()
775 ],
776 [
777 'data' => 'none',
778 'label' => $this->msg( 'tpt-fuzzy-select-none' )->text()
779 ],
780 [
781 'data' => 'custom',
782 'label' => $this->msg( 'tpt-fuzzy-select-custom' )->text()
783 ]
784 ],
785 'value' => 'custom'
786 ] ),
787 [
788 'label' => $this->msg( 'tpt-fuzzy-select-label' )->text(),
789 'align' => 'left',
790 ]
791 ) )->toString();
792 }
793
794 // General area
795 if ( $hideUnchangedUnitToggle !== '' || $requireUpdatesDropdown !== '' ) {
796 $out->addHTML( MessageWebImporter::makeSectionElement(
797 $this->msg( 'tpt-general-area-header' )->text(),
798 'general',
799 $hideUnchangedUnitToggle . $requireUpdatesDropdown
800 ) );
801
802 $out->addHTML( '<hr>' );
803 }
804
805 foreach ( $operation->getUnits() as $s ) {
806 if ( $s->id === TranslatablePage::DISPLAY_TITLE_UNIT_ID ) {
807 // Set section type as new if title previously unchecked
808 if ( !$page->hasPageDisplayTitle() ) {
809 $s->type = 'new';
810 }
811 $translationTitleStateReason = null;
812 if ( $operation->titleTranslationState === TranslateTitleEnum::DISABLED ) {
813 $translationTitleStateReason = $operation->titleTranslationStateReason ??
814 $this->msg( 'tpt-translate-title-disabled' )->text();
815 }
816
817 // Checkbox for page title optional translation
818 $checkBox = new FieldLayout(
819 new CheckboxInputWidget( [
820 'name' => 'translatetitle',
821 'selected' => $operation->titleTranslationState === TranslateTitleEnum::DEFAULT_CHECKED,
822 'disabled' => $operation->titleTranslationState === TranslateTitleEnum::DISABLED,
823 ] ),
824 [
825 'label' => $this->msg( 'tpt-translate-title' )->text(),
826 'align' => 'inline',
827 'classes' => [ 'mw-tpt-m-vertical' ],
828 'help' => $translationTitleStateReason,
829 'helpInline' => true,
830 ]
831 );
832 $out->addHTML( $checkBox->toString() );
833 }
834
835 if ( $s->type === 'new' ) {
836 $hasChanges = true;
837 $name = $this->msg( 'tpt-section-new', $s->id )->parse();
838 } else {
839 $name = $this->msg( 'tpt-section', $s->id, $this->getTranslationsLink( $page, $s ) )->parse();
840 }
841
842 if ( $s->type === 'changed' ) {
843 $hasChanges = true;
844 $diff = new DifferenceEngine();
845 $diff->setTextLanguage( $sourceLanguage );
846 $diff->setReducedLineNumbers();
847
848 $tpTitle = $page->getTitle();
849 $oldContent = ContentHandler::makeContent( $s->getOldText(), $tpTitle );
850 $oldRevision = new MutableRevisionRecord( $tpTitle );
851 $oldRevision->setContent( SlotRecord::MAIN, $oldContent );
852
853 $newContent = ContentHandler::makeContent( $s->getText(), $tpTitle );
854 $newRevision = new MutableRevisionRecord( $tpTitle );
855 $newRevision->setContent( SlotRecord::MAIN, $newContent );
856
857 $diff->setRevisions( $oldRevision, $newRevision );
858
859 $text = $diff->getDiff( $diffOld, $diffNew );
860 $diffOld = $diffNew = null;
861 $diff->showDiffStyle();
862
863 $checkLabel = new FieldLayout(
864 new CheckboxInputWidget( [
865 'name' => 'tpt-sect-fuzzy[]',
866 'value' => $s->id,
867 'selected' => !$s->onlyTvarsChanged()
868 ] ),
869 [
870 'label' => $this->msg( 'tpt-action-fuzzy' )->text(),
871 'align' => 'inline',
872 'classes' => [ 'mw-tpt-m-vertical', 'mw-tpt-action-field' ],
873 ]
874 );
875 $text = $checkLabel->toString() . $text;
876 } else {
877 $text = Utilities::convertWhiteSpaceToHTML( $s->getText() );
878 }
879
880 # For changed text, the language is set by $diff->setTextLanguage()
881 $lang = $s->type === 'changed' ? null : $sourceLanguage;
882 $out->addHTML( MessageWebImporter::makeSectionElement(
883 $name,
884 $s->type,
885 $text,
886 $lang,
887 $s->id === TranslatablePage::DISPLAY_TITLE_UNIT_ID ?
888 [ 'mw-tpt-sp-section-type-title' ] :
889 []
890 ) );
891
892 foreach ( $s->getIssues() as $issue ) {
893 $severity = $issue->getSeverity();
894 if ( $severity === TranslationUnitIssue::WARNING ) {
895 $box = Html::warningBox( $this->msg( $issue )->escaped() );
896 } elseif ( $severity === TranslationUnitIssue::ERROR ) {
897 $box = Html::errorBox( $this->msg( $issue )->escaped() );
898 } else {
899 throw new UnexpectedValueException(
900 "Unknown severity: $severity for key: {$issue->getKey()}"
901 );
902 }
903
904 $out->addHTML( $box );
905 }
906 }
907
908 if ( $operation->getDeletedUnits() ) {
909 $hasChanges = true;
910 $out->wrapWikiMsg( '==$1==', 'tpt-sections-deleted' );
911
912 foreach ( $operation->getDeletedUnits() as $s ) {
913 $name = $this->msg( 'tpt-section-deleted', $s->id, $this->getTranslationsLink( $page, $s ) )->parse();
914 $text = Utilities::convertWhiteSpaceToHTML( $s->getText() );
915 $out->addHTML( MessageWebImporter::makeSectionElement(
916 $name,
917 'deleted',
918 $text,
919 $sourceLanguage
920 ) );
921 }
922 }
923
924 if ( !$hasChanges ) {
925 $out->wrapWikiMsg( Html::successBox( '$1' ), 'tpt-mark-nochanges' );
926 }
927
928 // Display template changes if applicable
929 $markedTag = $page->getMarkedTag();
930 if ( $markedTag !== null ) {
931 $newTemplate = $operation->getParserOutput()->sourcePageTemplateForDiffs();
932 $tpTitle = $page->getTitle();
933 $oldPage = TranslatablePage::newFromRevision( $tpTitle, $markedTag );
934 $oldTemplate = $this->translatablePageParser
935 ->parse( $oldPage->getText() )
936 ->sourcePageTemplateForDiffs();
937
938 if ( $oldTemplate !== $newTemplate ) {
939 $out->wrapWikiMsg( '==$1==', 'tpt-sections-template' );
940
941 $diff = new DifferenceEngine();
942 $diff->setTextLanguage( $sourceLanguage );
943
944 $oldContent = ContentHandler::makeContent( $oldTemplate, $tpTitle );
945 $oldRevision = new MutableRevisionRecord( $tpTitle );
946 $oldRevision->setContent( SlotRecord::MAIN, $oldContent );
947
948 $newContent = ContentHandler::makeContent( $newTemplate, $tpTitle );
949 $newRevision = new MutableRevisionRecord( $tpTitle );
950 $newRevision->setContent( SlotRecord::MAIN, $newContent );
951
952 $diff->setRevisions( $oldRevision, $newRevision );
953
954 $text = $diff->getDiff(
955 $this->msg( 'tpt-diff-old' )->escaped(),
956 $this->msg( 'tpt-diff-new' )->escaped()
957 );
958 $diff->showDiffStyle();
959 $diff->setReducedLineNumbers();
960
961 $out->addHTML( Html::rawElement( 'div', [], $text ) );
962 }
963 }
964
965 $this->priorityLanguagesForm( $page );
966
967 // If an existing page does not have the supportsTransclusion flag, keep the checkbox unchecked,
968 // If the page is being marked for translation for the first time, the checkbox can be checked
969 $this->templateTransclusionForm( $page, $page->supportsTransclusion() ?? $operation->isFirstMark() );
970
971 $version = $this->messageGroupMetadata->getWithDefaultValue(
972 $page->getMessageGroupId(), 'version', TranslatablePageMarker::DEFAULT_SYNTAX_VERSION
973 );
974 $this->syntaxVersionForm( $version, $operation->isFirstMark() );
975
976 $submitButton = new FieldLayout(
977 new ButtonInputWidget( [
978 'label' => $this->msg( 'tpt-submit' )->text(),
979 'type' => 'submit',
980 'flags' => [ 'primary', 'progressive' ],
981 ] ),
982 [
983 'label' => null,
984 'align' => 'top',
985 ]
986 );
987
988 $out->addHTML( $submitButton->toString() );
989 $out->addHTML( '</form>' );
990 }
991
992 private function getTranslationsLink( TranslatablePage $page, TranslationUnit $section ): string {
993 $unitTitle = Title::makeTitle(
994 NS_TRANSLATIONS,
995 $page->getTitle()->getPrefixedDBkey() . '/' . $section->id
996 );
997 return SpecialPage::getTitleFor( 'Translations', $unitTitle->getPrefixedText() )
998 ->getPrefixedText();
999 }
1000
1001 private function priorityLanguagesForm( TranslatablePage $page ): void {
1002 $groupId = $page->getMessageGroupId();
1003 $interfaceLanguage = $this->getLanguage()->getCode();
1004 $storedLanguages = (string)$this->messageGroupMetadata->get( $groupId, 'prioritylangs' );
1005 $default = $storedLanguages !== '' ? explode( ',', $storedLanguages ) : [];
1006
1007 $priorityReason = $this->messageGroupMetadata->get( $groupId, 'priorityreason' );
1008 $priorityReason = $priorityReason !== false ? $priorityReason : '';
1009
1010 $form = new FieldsetLayout( [
1011 'items' => [
1012 new FieldLayout(
1013 new \OOUI\Widget( [
1014 'content' => new HtmlSnippet( ( new LanguageSelectWidget( [
1015 'name' => 'prioritylangs[]',
1016 'languages' => $this->getLanguageOptions( $interfaceLanguage ),
1017 'cssclass' => 'cdx-select',
1018 'value' => $default,
1019 'multiple' => true,
1020 'size' => 8
1021 ] ) )->toString() )
1022 ] ),
1023 [
1024 'label' => $this->msg( 'tpt-select-prioritylangs' )->text(),
1025 'align' => 'top',
1026 'helpInline' => true,
1027 ]
1028 ),
1029 new FieldLayout(
1030 new CheckboxInputWidget( [
1031 'name' => 'forcelimit',
1032 'selected' => $this->messageGroupMetadata->get( $groupId, 'priorityforce' ) === 'on',
1033 ] ),
1034 [
1035 'label' => $this->msg( 'tpt-select-prioritylangs-force' )->text(),
1036 'align' => 'inline',
1037 'help' => $this->msg( 'tpt-select-no-prioritylangs-force' )->text(),
1038 'helpInline' => true,
1039 ]
1040 ),
1041 new FieldLayout(
1042 new TextInputWidget( [
1043 'name' => 'priorityreason',
1044 'value' => $priorityReason
1045 ] ),
1046 [
1047 'label' => $this->msg( 'tpt-select-prioritylangs-reason' )->text(),
1048 'align' => 'top',
1049 ]
1050 ),
1051
1052 ],
1053 ] );
1054
1055 $this->getOutput()->wrapWikiMsg( '==$1==', 'tpt-sections-prioritylangs' );
1056 $this->getOutput()->addHTML( $form->toString() );
1057 }
1058
1059 private function getLanguageOptions( ?string $interfaceLanguage = null ): array {
1060 $languages = Utilities::getLanguageNames( $interfaceLanguage );
1061 $options = [];
1062 foreach ( $languages as $code => $name ) {
1063 $options[$code] = $name;
1064 }
1065
1066 return $options;
1067 }
1068
1069 private function syntaxVersionForm( string $version, bool $firstMark ): void {
1070 $out = $this->getOutput();
1071
1072 if ( $version === TranslatablePageMarker::LATEST_SYNTAX_VERSION || $firstMark ) {
1073 return;
1074 }
1075
1076 $out->wrapWikiMsg( '==$1==', 'tpt-sections-syntaxversion' );
1077 $out->addWikiMsg(
1078 'tpt-syntaxversion-text',
1079 '<code>' . wfEscapeWikiText( '<span lang="en" dir="ltr">...</span>' ) . '</code>',
1080 '<code>' . wfEscapeWikiText( '<translate nowrap>...</translate>' ) . '</code>'
1081 );
1082
1083 $checkBox = new FieldLayout(
1084 new CheckboxInputWidget( [
1085 'name' => 'use-latest-syntax'
1086 ] ),
1087 [
1088 'label' => $out->msg( 'tpt-syntaxversion-label' )->text(),
1089 'align' => 'inline',
1090 ]
1091 );
1092
1093 $out->addHTML( $checkBox->toString() );
1094 }
1095
1096 private function templateTransclusionForm( TranslatablePage $page, bool $supportsTransclusion ): void {
1097 $out = $this->getOutput();
1098 $out->wrapWikiMsg( '==$1==', 'tpt-transclusion' );
1099
1100 $checkBox = new FieldLayout(
1101 new CheckboxInputWidget( [
1102 'name' => 'transclusion',
1103 'selected' => $supportsTransclusion
1104 ] ),
1105 [
1106 'label' => $out->msg( 'tpt-transclusion-label' )->text(),
1107 'align' => 'inline',
1108 'help' => $out->msg( 'tpt-transclusion-help' )
1109 ->params( $page->getTitle()->getSubpage( 'de' )->getPrefixedText() )
1110 ->text(),
1111 'helpInline' => true,
1112 ]
1113 );
1114
1115 $out->addHTML( $checkBox->toString() );
1116 }
1117
1118 private function getPriorityLanguage( WebRequest $request ): array {
1119 // Get the priority languages from the request
1120 // The form field uses prioritylangs[] which returns an array
1121 $priorityLanguages = $request->getArray( 'prioritylangs', [] );
1122 if ( $priorityLanguages ) {
1123 $priorityLanguages = array_unique( array_filter( $priorityLanguages ) );
1124 }
1125
1126 $forcePriorityLanguage = $request->getCheck( 'forcelimit' );
1127 $priorityLanguageReason = trim( $request->getText( 'priorityreason' ) );
1128
1129 return [ $priorityLanguages, $forcePriorityLanguage, $priorityLanguageReason ];
1130 }
1131
1132 private function getPageList( array $pages, string $type ): string {
1133 $items = [];
1134
1135 $tagDiscouraged = Html::element(
1136 'li',
1137 [ 'class' => 'mw-tpt-actions-discouraged' ],
1138 $this->msg( 'tpt-tag-discouraged' )->text()
1139 );
1140 $tagOldSyntax = Html::element(
1141 'li',
1142 [ 'class' => 'mw-tpt-actions-oldsyntax' ],
1143 $this->msg( 'tpt-tag-oldsyntax' )->text()
1144 );
1145 $tagNoTransclusionSupport = Html::element(
1146 'li',
1147 [ 'class' => 'mw-tpt-actions-notransclusion-support' ],
1148 $this->msg( 'tpt-tag-no-transclusion-support' )->text()
1149 );
1150
1151 foreach ( $pages as $page ) {
1152 $link = $this->getLinkRenderer()->makeKnownLink( $page['title'] );
1153 $acts = $this->actionLinks( $page, $type );
1154 $tags = [];
1155 if ( $page['discouraged'] ) {
1156 $tags[] = $tagDiscouraged;
1157 }
1158 if ( $type !== 'proposed' ) {
1159 if ( $page['version'] !== TranslatablePageMarker::LATEST_SYNTAX_VERSION ) {
1160 $tags[] = $tagOldSyntax;
1161 }
1162
1163 if ( $page['transclusion'] !== '1' ) {
1164 $tags[] = $tagNoTransclusionSupport;
1165 }
1166 }
1167
1168 // Include an actions list even if there are no tags so that JS discouragement can find it
1169 $tags = Html::rawElement(
1170 'ul',
1171 [ 'class' => 'mw-tpt-actions' ],
1172 implode( '', $tags )
1173 );
1174 $items[] = Html::rawElement(
1175 'li',
1176 [
1177 'class' => 'mw-tpt-pagelist-item',
1178 'data-target' => $page['title']->getPrefixedText(),
1179 ],
1180 "$link $tags $acts"
1181 );
1182 }
1183
1184 return '<ol>' . implode( '', $items ) . '</ol>';
1185 }
1186
1188 private function displayPagesWithProposedState( array $pagesWithProposedState ): string {
1189 $items = [];
1190 $preparePageAction = $this->msg( 'tpt-prepare-page' )->text();
1191 $preparePageTooltip = $this->msg( 'tpt-prepare-page-tooltip' )->text();
1192 $linkRenderer = $this->getLinkRenderer();
1193 foreach ( $pagesWithProposedState as $pageRecord ) {
1194 $link = $linkRenderer->makeKnownLink( $pageRecord );
1195 $action = $linkRenderer->makeKnownLink(
1196 SpecialPage::getTitleFor( 'PagePreparation' ),
1197 $preparePageAction,
1198 [ 'title' => $preparePageTooltip ],
1199 [ 'page' => ( Title::newFromPageReference( $pageRecord ) )->getPrefixedText() ]
1200 );
1201 $items[] = "<li class='mw-tpt-pagelist-item'>$link <div>$action</div></li>";
1202 }
1203 return '<ol>' . implode( '', $items ) . '</ol>';
1204 }
1205
1206 private function showTranslationSettings( Title $target, ?ErrorPageError $block ): void {
1207 $out = $this->getOutput();
1208 $out->setPageTitle( $this->msg( 'tpt-translation-settings-page-title' )->text() );
1209
1210 $currentState = $this->translatablePageStateStore->get( $target );
1211
1212 if ( !$this->translatablePageView->canManageTranslationSettings( $target, $this->getUser() ) ) {
1213 $out->wrapWikiMsg( Html::errorBox( '$1' ), 'tpt-translation-settings-restricted' );
1214 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
1215 return;
1216 }
1217
1218 if ( $block ) {
1219 $out->wrapWikiMsg( Html::errorBox( '$1' ), $block->getMessageObject() );
1220 }
1221
1222 if ( $currentState ) {
1223 $this->displayStateInfoMessage( $target, $currentState );
1224 }
1225
1226 $this->addPageForm( $target, 'mw-tpt-sp-settings', 'settings', null );
1227 $out->addHTML(
1228 Html::rawElement(
1229 'p',
1230 [ 'class' => 'mw-tpt-vm' ],
1231 Html::element( 'strong', [], $this->msg( 'tpt-translation-settings-subtitle' )->text() )
1232 )
1233 );
1234
1235 $currentStateId = $currentState ? $currentState->getStateId() : null;
1236 $options = new FieldsetLayout( [
1237 'items' => [
1238 new FieldLayout(
1239 new RadioInputWidget( [
1240 'name' => 'translatable-page-state',
1241 'value' => 'ignored',
1242 'selected' => $currentStateId === TranslatableBundleState::IGNORE
1243 ] ),
1244 [
1245 'label' => $this->msg( 'tpt-translation-settings-ignore' )->text(),
1246 'align' => 'inline',
1247 'help' => $this->msg( 'tpt-translation-settings-ignore-hint' )->text(),
1248 'helpInline' => true,
1249 ]
1250 ),
1251 new FieldLayout(
1252 new RadioInputWidget( [
1253 'name' => 'translatable-page-state',
1254 'value' => 'unstable',
1255 'selected' => $currentStateId === null
1256 ] ),
1257 [
1258 'label' => $this->msg( 'tpt-translation-settings-unstable' )->text(),
1259 'align' => 'inline',
1260 'help' => $this->msg( 'tpt-translation-settings-unstable-hint' )->text(),
1261 'helpInline' => true,
1262 ]
1263 ),
1264 new FieldLayout(
1265 new RadioInputWidget( [
1266 'name' => 'translatable-page-state',
1267 'value' => 'proposed',
1268 'selected' => $currentStateId === TranslatableBundleState::PROPOSE
1269 ] ),
1270 [
1271 'label' => $this->msg( 'tpt-translation-settings-propose' )->text(),
1272 'align' => 'inline',
1273 'help' => $this->msg( 'tpt-translation-settings-propose-hint' )->text(),
1274 'helpInline' => true,
1275 ]
1276 ),
1277 ],
1278 ] );
1279
1280 $out->addHTML( $options->toString() );
1281
1282 $submitButton = new FieldLayout(
1283 new ButtonInputWidget( [
1284 'label' => $this->msg( 'tpt-translation-settings-save' )->text(),
1285 'type' => 'submit',
1286 'flags' => [ 'primary', 'progressive' ],
1287 'disabled' => $block !== null,
1288 ] )
1289 );
1290
1291 $out->addHTML( $submitButton->toString() );
1292 $out->addHTML( Html::closeElement( 'form' ) );
1293 }
1294
1295 private function handleTranslationState( Title $title, string $selectedState ): void {
1296 $validStateValues = [ 'ignored', 'unstable', 'proposed' ];
1297 $out = $this->getOutput();
1298 if ( !in_array( $selectedState, $validStateValues ) ) {
1299 throw new InvalidArgumentException( "Invalid translation state selected: $selectedState" );
1300 }
1301
1302 $user = $this->getUser();
1303 if ( !$this->translatablePageView->canManageTranslationSettings( $title, $user ) ) {
1304 $this->showTranslationStateRestricted();
1305 return;
1306 }
1307
1308 $bundleState = TranslatableBundleState::newFromText( $selectedState );
1309 if ( $selectedState === 'unstable' ) {
1310 $this->translatablePageStateStore->remove( $title );
1311 } else {
1312 $this->translatablePageStateStore->set( $title, $bundleState );
1313 }
1314
1315 $this->displayStateInfoMessage( $title, $bundleState );
1316 $out->setPageTitle( $this->msg( 'tpt-translation-settings-page-title' )->text() );
1317 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
1318 }
1319
1320 private function addPageForm(
1321 Title $target,
1322 string $formClass,
1323 string $action,
1324 ?int $revision
1325 ): void {
1326 $formParams = [
1327 'method' => 'post',
1328 'action' => $this->getPageTitle()->getLocalURL(),
1329 'class' => $formClass
1330 ];
1331
1332 $this->getOutput()->addHTML(
1333 Html::openElement( 'form', $formParams ) .
1334 Html::hidden( 'do', $action ) .
1335 Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
1336 ( $revision ? Html::hidden( 'revision', $revision ) : '' ) .
1337 Html::hidden( 'target', $target->getPrefixedText() ) .
1338 Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() )
1339 );
1340 }
1341
1342 private function displayStateInfoMessage( Title $title, TranslatableBundleState $bundleState ): void {
1343 $stateId = $bundleState->getStateId();
1344 if ( $stateId === TranslatableBundleState::UNSTABLE ) {
1345 $infoMessage = $this->msg( 'tpt-translation-settings-unstable-notice' );
1346 } elseif ( $stateId === TranslatableBundleState::PROPOSE ) {
1347 $userHasPageTranslationRight = $this->getUser()->isAllowed( 'pagetranslation' );
1348 if ( $userHasPageTranslationRight ) {
1349 $infoMessage = $this->msg( 'tpt-translation-settings-proposed-pagetranslation-notice' )->params(
1350 'https://www.mediawiki.org/wiki/Special:MyLanguage/' .
1351 'Help:Extension:Translate/Page_translation_administration',
1352 $title->getFullURL( 'action=edit' ),
1353 SpecialPage::getTitleFor( 'PagePreparation' )
1354 ->getFullURL( [ 'page' => $title->getPrefixedText() ] )
1355 );
1356 } else {
1357 $infoMessage = $this->msg( 'tpt-translation-settings-proposed-editor-notice' );
1358 }
1359 } else {
1360 $infoMessage = $this->msg( 'tpt-translation-settings-ignored-notice' );
1361 }
1362
1363 $this->getOutput()->wrapWikiMsg( Html::noticeBox( '$1', '' ), $infoMessage );
1364 }
1365
1366 private function getBlock( WebRequest $request, User $user, Title $title ): ?ErrorPageError {
1367 if ( $this->permissionManager->isBlockedFrom( $user, $title, !$request->wasPosted() ) ) {
1368 $block = $user->getBlock();
1369 if ( $block ) {
1370 return new UserBlockedError(
1371 $block,
1372 $user,
1373 $this->getLanguage(),
1374 $request->getIP()
1375 );
1376 }
1377
1378 return new PermissionsError( 'pagetranslation', [ 'badaccess-group0' ] );
1379 }
1380
1381 return null;
1382 }
1383
1384 private function showTranslationStateRestricted(): void {
1385 $out = $this->getOutput();
1386 $out->wrapWikiMsg( Html::errorBox( "$1" ), 'tpt-translation-settings-restricted' );
1387 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
1388 }
1389}
return[ 'Translate:AggregateGroupManager'=> static function(MediaWikiServices $services):AggregateGroupManager { return new AggregateGroupManager($services->getTitleFactory(), $services->get( 'Translate:MessageGroupMetadata'));}, 'Translate:AggregateGroupMessageGroupFactory'=> static function(MediaWikiServices $services):AggregateGroupMessageGroupFactory { return new AggregateGroupMessageGroupFactory($services->get( 'Translate:MessageGroupMetadata'));}, 'Translate:ConfigHelper'=> static function():ConfigHelper { return new ConfigHelper();}, 'Translate:CsvTranslationImporter'=> static function(MediaWikiServices $services):CsvTranslationImporter { return new CsvTranslationImporter( $services->getWikiPageFactory());}, 'Translate:EntitySearch'=> static function(MediaWikiServices $services):EntitySearch { return new EntitySearch($services->getMainWANObjectCache(), $services->getCollationFactory() ->makeCollation( 'uca-default-u-kn'), MessageGroups::singleton(), $services->getNamespaceInfo(), $services->get( 'Translate:MessageIndex'), $services->getTitleParser(), $services->getTitleFormatter());}, 'Translate:ExternalMessageSourceStateComparator'=> static function(MediaWikiServices $services):ExternalMessageSourceStateComparator { return new ExternalMessageSourceStateComparator(new SimpleStringComparator(), $services->getRevisionLookup(), $services->getPageStore());}, 'Translate:ExternalMessageSourceStateImporter'=> static function(MediaWikiServices $services):ExternalMessageSourceStateImporter { return new ExternalMessageSourceStateImporter($services->get( 'Translate:GroupSynchronizationCache'), $services->getJobQueueGroup(), LoggerFactory::getInstance(LogNames::GROUP_SYNCHRONIZATION), $services->get( 'Translate:MessageIndex'), $services->getTitleFactory(), $services->get( 'Translate:MessageGroupSubscription'), new ServiceOptions(ExternalMessageSourceStateImporter::CONSTRUCTOR_OPTIONS, $services->getMainConfig()));}, 'Translate:FileBasedMessageGroupFactory'=> static function(MediaWikiServices $services):FileBasedMessageGroupFactory { return new FileBasedMessageGroupFactory(new MessageGroupConfigurationParser(), $services->getContentLanguageCode() ->toString(), new ServiceOptions(FileBasedMessageGroupFactory::SERVICE_OPTIONS, $services->getMainConfig()),);}, 'Translate:FileFormatFactory'=> static function(MediaWikiServices $services):FileFormatFactory { return new FileFormatFactory( $services->getObjectFactory());}, 'Translate:GroupSynchronizationCache'=> static function(MediaWikiServices $services):GroupSynchronizationCache { return new GroupSynchronizationCache( $services->get( 'Translate:PersistentCache'));}, 'Translate:HookDefinedMessageGroupFactory'=> static function(MediaWikiServices $services):HookDefinedMessageGroupFactory { return new HookDefinedMessageGroupFactory( $services->get( 'Translate:HookRunner'));}, 'Translate:HookRunner'=> static function(MediaWikiServices $services):HookRunner { return new HookRunner( $services->getHookContainer());}, 'Translate:MessageBundleDependencyPurger'=> static function(MediaWikiServices $services):MessageBundleDependencyPurger { return new MessageBundleDependencyPurger( $services->get( 'Translate:TranslatableBundleFactory'));}, 'Translate:MessageBundleMessageGroupFactory'=> static function(MediaWikiServices $services):MessageBundleMessageGroupFactory { return new MessageBundleMessageGroupFactory($services->get( 'Translate:MessageGroupMetadata'), new ServiceOptions(MessageBundleMessageGroupFactory::SERVICE_OPTIONS, $services->getMainConfig()),);}, 'Translate:MessageBundleStore'=> static function(MediaWikiServices $services):MessageBundleStore { return new MessageBundleStore($services->get( 'Translate:RevTagStore'), $services->getJobQueueGroup(), $services->getLanguageNameUtils(), $services->get( 'Translate:MessageIndex'), $services->get( 'Translate:MessageGroupMetadata'));}, 'Translate:MessageBundleTranslationLoader'=> static function(MediaWikiServices $services):MessageBundleTranslationLoader { return new MessageBundleTranslationLoader( $services->getLanguageFallback());}, 'Translate:MessageGroupMetadata'=> static function(MediaWikiServices $services):MessageGroupMetadata { return new MessageGroupMetadata( $services->getConnectionProvider());}, 'Translate:MessageGroupReviewStore'=> static function(MediaWikiServices $services):MessageGroupReviewStore { return new MessageGroupReviewStore($services->getConnectionProvider(), $services->get( 'Translate:HookRunner'));}, 'Translate:MessageGroupStatsTableFactory'=> static function(MediaWikiServices $services):MessageGroupStatsTableFactory { return new MessageGroupStatsTableFactory($services->get( 'Translate:ProgressStatsTableFactory'), $services->getLinkRenderer(), $services->get( 'Translate:MessageGroupReviewStore'), $services->get( 'Translate:MessageGroupMetadata'), $services->getMainConfig() ->get( 'TranslateWorkflowStates') !==false);}, 'Translate:MessageGroupSubscription'=> static function(MediaWikiServices $services):MessageGroupSubscription { return new MessageGroupSubscription($services->get( 'Translate:MessageGroupSubscriptionStore'), $services->getJobQueueGroup(), $services->getUserIdentityLookup(), LoggerFactory::getInstance(LogNames::GROUP_SUBSCRIPTION), new ServiceOptions(MessageGroupSubscription::CONSTRUCTOR_OPTIONS, $services->getMainConfig()));}, 'Translate:MessageGroupSubscriptionHookHandler'=> static function(MediaWikiServices $services):?MessageGroupSubscriptionHookHandler { if(! $services->getExtensionRegistry() ->isLoaded( 'Echo')) { return null;} return new MessageGroupSubscriptionHookHandler($services->get( 'Translate:MessageGroupSubscription'), $services->getUserFactory());}, 'Translate:MessageGroupSubscriptionStore'=> static function(MediaWikiServices $services):MessageGroupSubscriptionStore { return new MessageGroupSubscriptionStore( $services->getConnectionProvider());}, 'Translate:MessageIndex'=> static function(MediaWikiServices $services):MessageIndex { $params=(array) $services->getMainConfig() ->get( 'TranslateMessageIndex');$class=array_shift( $params);$implementationMap=['HashMessageIndex'=> HashMessageIndex::class, 'CDBMessageIndex'=> CDBMessageIndex::class, 'DatabaseMessageIndex'=> DatabaseMessageIndex::class, 'hash'=> HashMessageIndex::class, 'cdb'=> CDBMessageIndex::class, 'database'=> DatabaseMessageIndex::class,];$messageIndexStoreClass=$implementationMap[$class] ?? $implementationMap['database'];return new MessageIndex(new $messageIndexStoreClass, $services->getMainWANObjectCache(), $services->getJobQueueGroup(), $services->get( 'Translate:HookRunner'), LoggerFactory::getInstance(LogNames::MAIN), $services->getMainObjectStash(), $services->getConnectionProvider(), new ServiceOptions(MessageIndex::SERVICE_OPTIONS, $services->getMainConfig()),);}, 'Translate:MessagePrefixStats'=> static function(MediaWikiServices $services):MessagePrefixStats { return new MessagePrefixStats( $services->getTitleParser());}, 'Translate:ParsingPlaceholderFactory'=> static function():ParsingPlaceholderFactory { return new ParsingPlaceholderFactory();}, 'Translate:PersistentCache'=> static function(MediaWikiServices $services):PersistentCache { return new PersistentDatabaseCache($services->getConnectionProvider(), $services->getJsonCodec());}, 'Translate:ProgressStatsTableFactory'=> static function(MediaWikiServices $services):ProgressStatsTableFactory { return new ProgressStatsTableFactory($services->getLinkRenderer(), $services->get( 'Translate:ConfigHelper'), $services->get( 'Translate:MessageGroupMetadata'));}, 'Translate:RevTagStore'=> static function(MediaWikiServices $services):RevTagStore { return new RevTagStore( $services->getConnectionProvider());}, 'Translate:SubpageListBuilder'=> static function(MediaWikiServices $services):SubpageListBuilder { return new SubpageListBuilder($services->get( 'Translate:TranslatableBundleFactory'), $services->getLinkBatchFactory());}, 'Translate:TranslatableBundleDeleter'=> static function(MediaWikiServices $services):TranslatableBundleDeleter { return new TranslatableBundleDeleter($services->getMainObjectStash(), $services->getJobQueueGroup(), $services->get( 'Translate:SubpageListBuilder'), $services->get( 'Translate:TranslatableBundleFactory'));}, 'Translate:TranslatableBundleExporter'=> static function(MediaWikiServices $services):TranslatableBundleExporter { return new TranslatableBundleExporter($services->get( 'Translate:SubpageListBuilder'), $services->getWikiExporterFactory(), $services->getConnectionProvider());}, 'Translate:TranslatableBundleFactory'=> static function(MediaWikiServices $services):TranslatableBundleFactory { return new TranslatableBundleFactory($services->get( 'Translate:TranslatablePageStore'), $services->get( 'Translate:MessageBundleStore'));}, 'Translate:TranslatableBundleImporter'=> static function(MediaWikiServices $services):TranslatableBundleImporter { return new TranslatableBundleImporter($services->getWikiImporterFactory(), $services->get( 'Translate:TranslatablePageParser'), $services->getRevisionLookup(), $services->getNamespaceInfo(), $services->getTitleFactory(), $services->getFormatterFactory());}, 'Translate:TranslatableBundleMover'=> static function(MediaWikiServices $services):TranslatableBundleMover { return new TranslatableBundleMover($services->getMovePageFactory(), $services->getJobQueueGroup(), $services->getLinkBatchFactory(), $services->get( 'Translate:TranslatableBundleFactory'), $services->get( 'Translate:SubpageListBuilder'), $services->getConnectionProvider(), $services->getObjectCacheFactory(), $services->getMainConfig() ->get( 'TranslatePageMoveLimit'));}, 'Translate:TranslatableBundleStatusStore'=> static function(MediaWikiServices $services):TranslatableBundleStatusStore { return new TranslatableBundleStatusStore($services->getConnectionProvider() ->getPrimaryDatabase(), $services->getCollationFactory() ->makeCollation( 'uca-default-u-kn'), $services->getDBLoadBalancer() ->getMaintenanceConnectionRef(DB_PRIMARY));}, 'Translate:TranslatablePageMarker'=> static function(MediaWikiServices $services):TranslatablePageMarker { return new TranslatablePageMarker($services->getConnectionProvider(), $services->getJobQueueGroup(), $services->getLinkRenderer(), MessageGroups::singleton(), $services->get( 'Translate:MessageIndex'), $services->getTitleFormatter(), $services->getTitleParser(), $services->get( 'Translate:TranslatablePageParser'), $services->get( 'Translate:TranslatablePageStore'), $services->get( 'Translate:TranslatablePageStateStore'), $services->get( 'Translate:TranslationUnitStoreFactory'), $services->get( 'Translate:MessageGroupMetadata'), $services->getWikiPageFactory(), $services->get( 'Translate:TranslatablePageView'), $services->get( 'Translate:MessageGroupSubscription'), $services->getFormatterFactory(), $services->get( 'Translate:HookRunner'),);}, 'Translate:TranslatablePageMessageGroupFactory'=> static function(MediaWikiServices $services):TranslatablePageMessageGroupFactory { return new TranslatablePageMessageGroupFactory(new ServiceOptions(TranslatablePageMessageGroupFactory::SERVICE_OPTIONS, $services->getMainConfig()),);}, 'Translate:TranslatablePageParser'=> static function(MediaWikiServices $services):TranslatablePageParser { return new TranslatablePageParser($services->get( 'Translate:ParsingPlaceholderFactory'));}, 'Translate:TranslatablePageStateStore'=> static function(MediaWikiServices $services):TranslatablePageStateStore { return new TranslatablePageStateStore($services->get( 'Translate:PersistentCache'), $services->getPageStore());}, 'Translate:TranslatablePageStore'=> static function(MediaWikiServices $services):TranslatablePageStore { return new TranslatablePageStore($services->get( 'Translate:MessageIndex'), $services->getJobQueueGroup(), $services->get( 'Translate:RevTagStore'), $services->getConnectionProvider(), $services->get( 'Translate:TranslatableBundleStatusStore'), $services->get( 'Translate:TranslatablePageParser'), $services->get( 'Translate:MessageGroupMetadata'));}, 'Translate:TranslatablePageView'=> static function(MediaWikiServices $services):TranslatablePageView { return new TranslatablePageView($services->getConnectionProvider(), $services->get( 'Translate:TranslatablePageStateStore'), new ServiceOptions(TranslatablePageView::SERVICE_OPTIONS, $services->getMainConfig()));}, 'Translate:TranslateSandbox'=> static function(MediaWikiServices $services):TranslateSandbox { return new TranslateSandbox($services->getUserFactory(), $services->getConnectionProvider(), $services->getPermissionManager(), $services->getAuthManager(), $services->getUserGroupManager(), $services->getActorStore(), $services->getUserOptionsManager(), $services->getJobQueueGroup(), $services->get( 'Translate:HookRunner'), new ServiceOptions(TranslateSandbox::CONSTRUCTOR_OPTIONS, $services->getMainConfig()));}, 'Translate:TranslationStashReader'=> static function(MediaWikiServices $services):TranslationStashReader { return new TranslationStashStorage( $services->getConnectionProvider() ->getPrimaryDatabase());}, 'Translate:TranslationStatsDataProvider'=> static function(MediaWikiServices $services):TranslationStatsDataProvider { return new TranslationStatsDataProvider(new ServiceOptions(TranslationStatsDataProvider::CONSTRUCTOR_OPTIONS, $services->getMainConfig()), $services->getObjectFactory(), $services->getConnectionProvider());}, 'Translate:TranslationUnitStoreFactory'=> static function(MediaWikiServices $services):TranslationUnitStoreFactory { return new TranslationUnitStoreFactory( $services->getDBLoadBalancer());}, 'Translate:TranslatorActivity'=> static function(MediaWikiServices $services):TranslatorActivity { $query=new TranslatorActivityQuery($services->getMainConfig(), $services->getConnectionProvider());return new TranslatorActivity($services->getMainObjectStash(), $query, $services->getJobQueueGroup());}, 'Translate:TtmServerFactory'=> static function(MediaWikiServices $services):TtmServerFactory { $config=$services->getMainConfig();$default=$config->get( 'TranslateTranslationDefaultService');if( $default===false) { $default=null;} return new TtmServerFactory( $config->get( 'TranslateTranslationServices'), $default);}, 'Translate:WorkflowStatesMessageGroupLoader'=> static function(MediaWikiServices $services):WorkflowStatesMessageGroupLoader { return new WorkflowStatesMessageGroupLoader(new ServiceOptions(WorkflowStatesMessageGroupLoader::CONSTRUCTOR_OPTIONS, $services->getMainConfig()),);},]
@phpcs-require-sorted-array
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.
Logic and code to generate various aspects related to how translatable pages are displayed.
Essentially random collection of helper functions, similar to GlobalFunctions.php.
Definition Utilities.php:30