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