Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
PageTranslationSpecialPage.php
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\PageTranslation;
5
6use ContentHandler;
7use DifferenceEngine;
8use Html;
9use JobQueueGroup;
10use ManualLogEntry;
11use MediaWiki\Cache\LinkBatchFactory;
17use MediaWiki\Extension\TranslationNotifications\SpecialNotifyTranslators;
18use MediaWiki\Languages\LanguageFactory;
19use MediaWiki\Languages\LanguageNameUtils;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Revision\RevisionRecord;
22use MediaWiki\User\UserIdentity;
23use Message;
25use MessageIndex;
26use MWException;
27use OOUI\ButtonInputWidget;
28use OOUI\CheckboxInputWidget;
29use OOUI\FieldLayout;
30use OOUI\FieldsetLayout;
31use OOUI\TextInputWidget;
32use PermissionsError;
33use SpecialPage;
34use Status;
35use Title;
37use UserBlockedError;
38use WebRequest;
39use Wikimedia\Rdbms\ILoadBalancer;
40use Wikimedia\Rdbms\IResultWrapper;
41use Xml;
42use function count;
43use function wfEscapeWikiText;
44use const EDIT_FORCE_BOT;
45use const EDIT_UPDATE;
46
58class PageTranslationSpecialPage extends SpecialPage {
59 private const LATEST_SYNTAX_VERSION = '2';
60 private const DEFAULT_SYNTAX_VERSION = '1';
61 private const DISPLAY_STATUS_MAPPING = [
62 TranslatablePageStatus::PROPOSED => 'proposed',
63 TranslatablePageStatus::ACTIVE => 'active',
64 TranslatablePageStatus::OUTDATED => 'outdated',
65 TranslatablePageStatus::BROKEN => 'broken'
66 ];
68 private $languageNameUtils;
70 private $languageFactory;
72 private $translationUnitStoreFactory;
74 private $translatablePageParser;
76 private $linkBatchFactory;
78 private $jobQueueGroup;
80 private $loadBalancer;
82 private $messageIndex;
83
84 public function __construct(
85 LanguageNameUtils $languageNameUtils,
86 LanguageFactory $languageFactory,
87 TranslationUnitStoreFactory $translationUnitStoreFactory,
88 TranslatablePageParser $translatablePageParser,
89 LinkBatchFactory $linkBatchFactory,
90 JobQueueGroup $jobQueueGroup,
91 ILoadBalancer $loadBalancer,
92 MessageIndex $messageIndex
93 ) {
94 parent::__construct( 'PageTranslation' );
95 $this->languageNameUtils = $languageNameUtils;
96 $this->languageFactory = $languageFactory;
97 $this->translationUnitStoreFactory = $translationUnitStoreFactory;
98 $this->translatablePageParser = $translatablePageParser;
99 $this->linkBatchFactory = $linkBatchFactory;
100 $this->jobQueueGroup = $jobQueueGroup;
101 $this->loadBalancer = $loadBalancer;
102 $this->messageIndex = $messageIndex;
103 }
104
105 public function doesWrites(): bool {
106 return true;
107 }
108
109 protected function getGroupName(): string {
110 return 'translation';
111 }
112
113 public function execute( $parameters ) {
114 $this->setHeaders();
115
116 $user = $this->getUser();
117 $request = $this->getRequest();
118
119 $target = $request->getText( 'target', $parameters ?? '' );
120 $revision = $request->getInt( 'revision', 0 );
121 $action = $request->getVal( 'do' );
122 $out = $this->getOutput();
123 $out->addModules( 'ext.translate.special.pagetranslation' );
124 $out->addHelpLink( 'Help:Extension:Translate/Page_translation_example' );
125 $out->enableOOUI();
126
127 if ( $target === '' ) {
128 $this->listPages();
129
130 return;
131 }
132
133 // Anything else than listing the pages need permissions
134 if ( !$user->isAllowed( 'pagetranslation' ) ) {
135 throw new PermissionsError( 'pagetranslation' );
136 }
137
138 $title = Title::newFromText( $target );
139 if ( !$title ) {
140 $out->wrapWikiMsg( Html::errorBox( '$1' ), [ 'tpt-badtitle', $target ] );
141 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
142
143 return;
144 } elseif ( !$title->exists() ) {
145 $out->wrapWikiMsg(
146 Html::errorBox( '$1' ),
147 [ 'tpt-nosuchpage', $title->getPrefixedText() ]
148 );
149 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
150
151 return;
152 }
153
154 // Check for blocks
155 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
156 if ( $permissionManager->isBlockedFrom( $user, $title, !$request->wasPosted() ) ) {
157 $block = $user->getBlock();
158 if ( $block ) {
159 throw new UserBlockedError(
160 $block,
161 $user,
162 $this->getLanguage(),
163 $request->getIP()
164 );
165 }
166
167 throw new PermissionsError( 'pagetranslation', [ 'badaccess-group0' ] );
168
169 }
170
171 // Check token for all POST actions here
172 $csrfTokenSet = $this->getContext()->getCsrfTokenSet();
173 if ( $request->wasPosted() && !$csrfTokenSet->matchTokenField( 'token' ) ) {
174 throw new PermissionsError( 'pagetranslation' );
175 }
176
177 if ( $action === 'mark' ) {
178 // Has separate form
179 $this->onActionMark( $title, $revision );
180
181 return;
182 }
183
184 // On GET requests, show form which has token
185 if ( !$request->wasPosted() ) {
186 if ( $action === 'unlink' ) {
187 $this->showUnlinkConfirmation( $title );
188 } else {
189 $params = [
190 'do' => $action,
191 'target' => $title->getPrefixedText(),
192 'revision' => $revision,
193 ];
194 $this->showGenericConfirmation( $params );
195 }
196
197 return;
198 }
199
200 if ( $action === 'discourage' || $action === 'encourage' ) {
202 $current = MessageGroups::getPriority( $id );
203
204 if ( $action === 'encourage' ) {
205 $new = '';
206 } else {
207 $new = 'discouraged';
208 }
209
210 if ( $new !== $current ) {
211 MessageGroups::setPriority( $id, $new );
212 $entry = new ManualLogEntry( 'pagetranslation', $action );
213 $entry->setPerformer( $user );
214 $entry->setTarget( $title );
215 $logid = $entry->insert();
216 $entry->publish( $logid );
217 }
218
219 // Defer stats purging of parent aggregate groups. Shared groups can contain other
220 // groups as well, which we do not need to update. We could filter non-aggregate
221 // groups out, or use MessageGroups::getParentGroups, though it has an inconvenient
222 // return value format for this use case.
223 $group = MessageGroups::getGroup( $id );
224 $sharedGroupIds = MessageGroups::getSharedGroups( $group );
225 if ( $sharedGroupIds !== [] ) {
226 $job = MessageGroupStatsRebuildJob::newRefreshGroupsJob( $sharedGroupIds );
227 $this->jobQueueGroup->push( $job );
228 }
229
230 // Show updated page with a notice
231 $this->listPages();
232
233 return;
234 }
235
236 if ( $action === 'unlink' ) {
237 $page = TranslatablePage::newFromTitle( $title );
238
239 $content = ContentHandler::makeContent(
240 $page->getStrippedSourcePageText(),
241 $title
242 );
243
244 $status = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title )->doUserEditContent(
245 $content,
246 $this->getUser(),
247 $this->msg( 'tpt-unlink-summary' )->inContentLanguage()->text(),
248 EDIT_FORCE_BOT | EDIT_UPDATE
249 );
250
251 if ( !$status->isOK() ) {
252 $out->wrapWikiMsg(
253 Html::errorBox( '$1' ),
254 [ 'tpt-edit-failed', $status->getWikiText() ]
255 );
256 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
257
258 return;
259 }
260
261 $page = TranslatablePage::newFromTitle( $title );
262 $this->unmarkPage( $page, $user );
263 $out->wrapWikiMsg(
264 Html::successBox( '$1' ),
265 [ 'tpt-unmarked', $title->getPrefixedText() ]
266 );
267 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
268
269 return;
270 }
271
272 if ( $action === 'unmark' ) {
273 $page = TranslatablePage::newFromTitle( $title );
274 $this->unmarkPage( $page, $user );
275 $out->wrapWikiMsg(
276 Html::successBox( '$1' ),
277 [ 'tpt-unmarked', $title->getPrefixedText() ]
278 );
279 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
280 }
281 }
282
283 protected function onActionMark( Title $title, int $revision ): void {
284 $request = $this->getRequest();
285 $out = $this->getOutput();
286
287 $out->addModuleStyles( 'ext.translate.specialpages.styles' );
288
289 if ( $revision === 0 ) {
290 // Get the latest revision
291 $revision = (int)$title->getLatestRevID();
292 }
293
294 // This also catches the case where revision does not belong to the title
295 if ( $revision !== (int)$title->getLatestRevID() ) {
296 // We do want to notify the reviewer if the underlying page changes during review
297 $target = $title->getFullURL( [ 'oldid' => $revision ] );
298 $link = "<span class='plainlinks'>[$target $revision]</span>";
299 $out->wrapWikiMsg(
300 Html::warningBox( '$1' ),
301 [ 'tpt-oldrevision', $title->getPrefixedText(), $link ]
302 );
303 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
304
305 return;
306 }
307
308 // newFromRevision never fails, but getReadyTag might fail if revision does not belong
309 // to the page (checked above)
310 $page = TranslatablePage::newFromRevision( $title, $revision );
311 if ( $page->getReadyTag() !== $title->getLatestRevID() ) {
312 $out->wrapWikiMsg(
313 Html::errorBox( '$1' ),
314 [ 'tpt-notsuitable', $title->getPrefixedText(), Message::plaintextParam( '<translate>' ) ]
315 );
316 $out->addWikiMsg( 'tpt-list-pages-in-translations' );
317
318 return;
319 }
320
321 $firstMark = $page->getMarkedTag() === null;
322
323 $parse = $this->translatablePageParser->parse( $page->getText() );
324 [ $units, $deletedUnits ] = $this->prepareTranslationUnits( $page, $parse );
325
326 $error = $this->validateUnitIds( $units );
327
328 // Non-fatal error which prevents saving
329 if ( !$error && $request->wasPosted() ) {
330 // Check if user wants to translate title
331 // If not, remove it from the list of units
332 if ( !$request->getCheck( 'translatetitle' ) ) {
333 $units = array_filter( $units, static function ( $s ) {
334 return $s->id !== TranslatablePage::DISPLAY_TITLE_UNIT_ID;
335 } );
336 }
337
338 $setVersion = $firstMark || $request->getCheck( 'use-latest-syntax' );
339 $transclusion = $request->getCheck( 'transclusion' );
340
341 $err = $this->markForTranslation( $page, $parse, $units, $setVersion, $transclusion );
342
343 if ( $err ) {
344 call_user_func_array( [ $out, 'addWikiMsg' ], $err );
345 } else {
346 $this->showSuccess( $page, $firstMark, count( $units ) );
347 }
348
349 return;
350 }
351
352 $this->showPage( $page, $parse, $units, $deletedUnits, $firstMark );
353 }
354
362 private function showSuccess(
363 TranslatablePage $page, bool $firstMark, int $unitCount
364 ): void {
365 $titleText = $page->getTitle()->getPrefixedText();
366 $num = $this->getLanguage()->formatNum( $unitCount );
367 $link = SpecialPage::getTitleFor( 'Translate' )->getFullURL( [
368 'group' => $page->getMessageGroupId(),
369 'action' => 'page',
370 'filter' => '',
371 ] );
372
373 $this->getOutput()->wrapWikiMsg(
374 Html::successBox( '$1' ),
375 [ 'tpt-saveok', $titleText, $num, $link ]
376 );
377
378 // If the page is being marked for translation for the first time
379 // add a link to Special:PageMigration.
380 if ( $firstMark ) {
381 $this->getOutput()->addWikiMsg( 'tpt-saveok-first' );
382 }
383
384 // If TranslationNotifications is installed, and the user can notify
385 // translators, add a convenience link.
386 if ( method_exists( SpecialNotifyTranslators::class, 'execute' ) &&
387 $this->getUser()->isAllowed( SpecialNotifyTranslators::$right )
388 ) {
389 $link = SpecialPage::getTitleFor( 'NotifyTranslators' )->getFullURL(
390 [ 'tpage' => $page->getTitle()->getArticleID() ]
391 );
392 $this->getOutput()->addWikiMsg( 'tpt-offer-notify', $link );
393 }
394
395 $this->getOutput()->addWikiMsg( 'tpt-list-pages-in-translations' );
396 }
397
398 protected function showGenericConfirmation( array $params ): void {
399 $formParams = [
400 'method' => 'post',
401 'action' => $this->getPageTitle()->getLocalURL(),
402 ];
403
404 $params['title'] = $this->getPageTitle()->getPrefixedText();
405 $params['token'] = $this->getContext()->getCsrfTokenSet()->getToken();
406
407 $hidden = '';
408 foreach ( $params as $key => $value ) {
409 $hidden .= Html::hidden( $key, $value );
410 }
411
412 $this->getOutput()->addHTML(
413 Html::openElement( 'form', $formParams ) .
414 $hidden .
415 $this->msg( 'tpt-generic-confirm' )->parseAsBlock() .
416 Xml::submitButton(
417 $this->msg( 'tpt-generic-button' )->text(),
418 [ 'class' => 'mw-ui-button mw-ui-progressive' ]
419 ) .
420 Html::closeElement( 'form' )
421 );
422 }
423
424 protected function showUnlinkConfirmation( Title $target ): void {
425 $formParams = [
426 'method' => 'post',
427 'action' => $this->getPageTitle()->getLocalURL(),
428 ];
429
430 $this->getOutput()->addHTML(
431 Html::openElement( 'form', $formParams ) .
432 Html::hidden( 'do', 'unlink' ) .
433 Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
434 Html::hidden( 'target', $target->getPrefixedText() ) .
435 Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() ) .
436 $this->msg( 'tpt-unlink-confirm', $target->getPrefixedText() )->parseAsBlock() .
437 Xml::submitButton(
438 $this->msg( 'tpt-unlink-button' )->text(),
439 [ 'class' => 'mw-ui-button mw-ui-destructive' ]
440 ) .
441 Html::closeElement( 'form' )
442 );
443 }
444
445 protected function unmarkPage( TranslatablePage $page, UserIdentity $user ): void {
446 $page->unmarkTranslatablePage();
447 $page->getTitle()->invalidateCache();
448
449 $entry = new ManualLogEntry( 'pagetranslation', 'unmark' );
450 $entry->setPerformer( $user );
451 $entry->setTarget( $page->getTitle() );
452 $logid = $entry->insert();
453 $entry->publish( $logid );
454 }
455
460 public static function loadPagesFromDB(): IResultWrapper {
461 $dbr = Utilities::getSafeReadDB();
462 $tables = [ 'page', 'revtag' ];
463 $vars = [
464 'page_id',
465 'page_namespace',
466 'page_title',
467 'page_latest',
468 'MAX(rt_revision) AS rt_revision',
469 'rt_type'
470 ];
471 $conds = [
472 'page_id=rt_page',
473 'rt_type' => [ RevTagStore::TP_MARK_TAG, RevTagStore::TP_READY_TAG ],
474 ];
475 $options = [
476 'ORDER BY' => 'page_namespace, page_title',
477 'GROUP BY' => 'page_id, page_namespace, page_title, page_latest, rt_type',
478 ];
479
480 return $dbr->select( $tables, $vars, $conds, __METHOD__, $options );
481 }
482
487 public static function buildPageArray( IResultWrapper $res ): array {
488 $pages = [];
489 foreach ( $res as $r ) {
490 // We have multiple rows for same page, because of different tags
491 if ( !isset( $pages[$r->page_id] ) ) {
492 $pages[$r->page_id] = [];
493 $title = Title::newFromRow( $r );
494 $pages[$r->page_id]['title'] = $title;
495 $pages[$r->page_id]['latest'] = (int)$title->getLatestRevID();
496 }
497
498 $tag = $r->rt_type;
499 $pages[$r->page_id][$tag] = (int)$r->rt_revision;
500 }
501
502 return $pages;
503 }
504
512 private function classifyPages( array $pages ): array {
513 // Preload stuff for performance
514 $messageGroupIdsForPreload = [];
515 foreach ( $pages as $i => $page ) {
516 $id = TranslatablePage::getMessageGroupIdFromTitle( $page['title'] );
517 $messageGroupIdsForPreload[] = $id;
518 $pages[$i]['groupid'] = $id;
519 }
520 // Performance optimization: load only data we need to classify the pages
521 $metadata = TranslateMetadata::loadBasicMetadataForTranslatablePages(
522 $messageGroupIdsForPreload,
523 [ 'transclusion', 'version' ]
524 );
525
526 $out = [
527 // The ideal state for pages: marked and up to date
528 'active' => [],
529 'proposed' => [],
530 'outdated' => [],
531 'broken' => [],
532 ];
533
534 foreach ( $pages as $page ) {
535 $groupId = $page['groupid'];
536 $group = MessageGroups::getGroup( $groupId );
537 $page['discouraged'] = MessageGroups::getPriority( $group ) === 'discouraged';
538 $page['version'] = $metadata[$groupId]['version'] ?? self::DEFAULT_SYNTAX_VERSION;
539 $page['transclusion'] = $metadata[$groupId]['transclusion'] ?? false;
540
541 // TODO: Eventually we should query the status directly from the TranslatableBundleStore
542 $tpStatus = TranslatablePage::determineStatus(
543 $page[RevTagStore::TP_READY_TAG] ?? null,
544 $page[RevTagStore::TP_MARK_TAG] ?? null,
545 $page['latest']
546 );
547
548 if ( !$tpStatus ) {
549 // Ignore pages for which status could not be determined.
550 continue;
551 }
552
553 $out[self::DISPLAY_STATUS_MAPPING[$tpStatus->getId()]][] = $page;
554 }
555
556 return $out;
557 }
558
559 public function listPages(): void {
560 $out = $this->getOutput();
561
562 $res = self::loadPagesFromDB();
563 $allPages = self::buildPageArray( $res );
564 if ( !count( $allPages ) ) {
565 $out->addWikiMsg( 'tpt-list-nopages' );
566
567 return;
568 }
569
570 $lb = $this->linkBatchFactory->newLinkBatch();
571 $lb->setCaller( __METHOD__ );
572 foreach ( $allPages as $page ) {
573 $lb->addObj( $page['title'] );
574 }
575 $lb->execute();
576
577 $types = $this->classifyPages( $allPages );
578
579 $pages = $types['proposed'];
580 if ( $pages ) {
581 $out->wrapWikiMsg( '== $1 ==', 'tpt-new-pages-title' );
582 $out->addWikiMsg( 'tpt-new-pages', count( $pages ) );
583 $out->addHTML( $this->getPageList( $pages, 'proposed' ) );
584 }
585
586 $pages = $types['broken'];
587 if ( $pages ) {
588 $out->wrapWikiMsg( '== $1 ==', 'tpt-other-pages-title' );
589 $out->addWikiMsg( 'tpt-other-pages', count( $pages ) );
590 $out->addHTML( $this->getPageList( $pages, 'broken' ) );
591 }
592
593 $pages = $types['outdated'];
594 if ( $pages ) {
595 $out->wrapWikiMsg( '== $1 ==', 'tpt-outdated-pages-title' );
596 $out->addWikiMsg( 'tpt-outdated-pages', count( $pages ) );
597 $out->addHTML( $this->getPageList( $pages, 'outdated' ) );
598 }
599
600 $pages = $types['active'];
601 if ( $pages ) {
602 $out->wrapWikiMsg( '== $1 ==', 'tpt-old-pages-title' );
603 $out->addWikiMsg( 'tpt-old-pages', count( $pages ) );
604 $out->addHTML( $this->getPageList( $pages, 'active' ) );
605 }
606 }
607
608 private function actionLinks( array $page, string $type ): string {
609 // Performance optimization to avoid calling $this->msg in a loop
610 static $messageCache = null;
611 if ( $messageCache === null ) {
612 $messageCache = [
613 'mark' => $this->msg( 'tpt-rev-mark' )->text(),
614 'mark-tooltip' => $this->msg( 'tpt-rev-mark-tooltip' )->text(),
615 'encourage' => $this->msg( 'tpt-rev-encourage' )->text(),
616 'encourage-tooltip' => $this->msg( 'tpt-rev-encourage-tooltip' )->text(),
617 'discourage' => $this->msg( 'tpt-rev-discourage' )->text(),
618 'discourage-tooltip' => $this->msg( 'tpt-rev-discourage-tooltip' )->text(),
619 'unmark' => $this->msg( 'tpt-rev-unmark' )->text(),
620 'unmark-tooltip' => $this->msg( 'tpt-rev-unmark-tooltip' )->text(),
621 'pipe-separator' => $this->msg( 'pipe-separator' )->escaped(),
622 ];
623 }
624
625 $actions = [];
627 $title = $page['title'];
628 $user = $this->getUser();
629
630 // Class to allow one-click POSTs
631 $js = [ 'class' => 'mw-translate-jspost' ];
632
633 if ( $user->isAllowed( 'pagetranslation' ) ) {
634 // Enable re-marking of all pages to allow changing of priority languages
635 // or migration to the new syntax version
636 if ( $type !== 'broken' ) {
637 $actions[] = $this->getLinkRenderer()->makeKnownLink(
638 $this->getPageTitle(),
639 $messageCache['mark'],
640 [ 'title' => $messageCache['mark-tooltip'] ],
641 [
642 'do' => 'mark',
643 'target' => $title->getPrefixedText(),
644 'revision' => $title->getLatestRevID(),
645 ]
646 );
647 }
648
649 if ( $type !== 'proposed' ) {
650 if ( $page['discouraged'] ) {
651 $actions[] = $this->getLinkRenderer()->makeKnownLink(
652 $this->getPageTitle(),
653 $messageCache['encourage'],
654 [ 'title' => $messageCache['encourage-tooltip'] ] + $js,
655 [
656 'do' => 'encourage',
657 'target' => $title->getPrefixedText(),
658 'revision' => -1,
659 ]
660 );
661 } else {
662 $actions[] = $this->getLinkRenderer()->makeKnownLink(
663 $this->getPageTitle(),
664 $messageCache['discourage'],
665 [ 'title' => $messageCache['discourage-tooltip'] ] + $js,
666 [
667 'do' => 'discourage',
668 'target' => $title->getPrefixedText(),
669 'revision' => -1,
670 ]
671 );
672 }
673
674 $actions[] = $this->getLinkRenderer()->makeKnownLink(
675 $this->getPageTitle(),
676 $messageCache['unmark'],
677 [ 'title' => $messageCache['unmark-tooltip'] ],
678 [
679 'do' => $type === 'broken' ? 'unmark' : 'unlink',
680 'target' => $title->getPrefixedText(),
681 'revision' => -1,
682 ]
683 );
684 }
685 }
686
687 if ( !$actions ) {
688 return '';
689 }
690
691 return '<div>' . implode( $messageCache['pipe-separator'], $actions ) . '</div>';
692 }
693
699 private function validateUnitIds( array $units ): bool {
700 $usedNames = [];
701 $status = Status::newGood();
702
703 $ic = preg_quote( TranslationUnit::UNIT_MARKER_INVALID_CHARS, '~' );
704 foreach ( $units as $s ) {
705 if ( preg_match( "~[$ic]~", $s->id ) ) {
706 $status->fatal( 'tpt-invalid', $s->id );
707 }
708
709 // We need to do checks for both new and existing units.
710 // Someone might have tampered with the page source adding
711 // duplicate or invalid markers.
712 if ( isset( $usedNames[$s->id] ) ) {
713 // If the same ID is used three or more times, the same
714 // error will be added more than once, but that's okay,
715 // Status::fatal will deduplicate
716 $status->fatal( 'tpt-duplicate', $s->id );
717 }
718 $usedNames[$s->id] = true;
719 }
720
721 if ( $status->isOK() ) {
722 return false;
723 } else {
724 $this->getOutput()->addHTML(
725 Html::errorBox(
726 $status->getHTML( false, false, $this->getLanguage() )
727 )
728 );
729 return true;
730 }
731 }
732
734 private function prepareTranslationUnits( TranslatablePage $page, ParserOutput $parse ): array {
735 $highest = (int)TranslateMetadata::get( $page->getMessageGroupId(), 'maxid' );
736
737 $store = $this->translationUnitStoreFactory->getReader( $page->getTitle() );
738 $storedUnits = $store->getUnits();
739 $parsedUnits = $parse->units();
740
741 // Prepend the display title unit, which is not part of the page contents
742 $displayTitle = new TranslationUnit(
743 $page->getTitle()->getPrefixedText(),
744 TranslatablePage::DISPLAY_TITLE_UNIT_ID
745 );
746 $parsedUnits = [ TranslatablePage::DISPLAY_TITLE_UNIT_ID => $displayTitle ] + $parsedUnits;
747
748 // Figure out the largest used translation unit id
749 foreach ( array_keys( $storedUnits ) as $key ) {
750 $highest = max( $highest, (int)$key );
751 }
752 foreach ( $parsedUnits as $_ ) {
753 $highest = max( $highest, (int)$_->id );
754 }
755
756 foreach ( $parsedUnits as $s ) {
757 $s->type = 'old';
758
759 if ( $s->id === TranslationUnit::NEW_UNIT_ID ) {
760 $s->type = 'new';
761 $s->id = (string)( ++$highest );
762 } else {
763 if ( isset( $storedUnits[$s->id] ) ) {
764 $storedText = $storedUnits[$s->id]->text;
765 if ( $s->text !== $storedText ) {
766 $s->type = 'changed';
767 $s->oldText = $storedText;
768 }
769 }
770 }
771 }
772
773 // Figure out which units were deleted by removing the still existing units
774 $deletedUnits = $storedUnits;
775 foreach ( $parsedUnits as $s ) {
776 unset( $deletedUnits[$s->id] );
777 }
778
779 return [ $parsedUnits, $deletedUnits ];
780 }
781
782 private function showPage(
783 TranslatablePage $page,
784 ParserOutput $parse,
785 array $sections,
786 array $deletedUnits,
787 bool $firstMark
788 ): void {
789 $out = $this->getOutput();
790 $out->setSubtitle( $this->getLinkRenderer()->makeKnownLink( $page->getTitle() ) );
791 $out->addWikiMsg( 'tpt-showpage-intro' );
792
793 $formParams = [
794 'method' => 'post',
795 'action' => $this->getPageTitle()->getLocalURL(),
796 'class' => 'mw-tpt-sp-markform',
797 ];
798
799 $out->addHTML(
800 Xml::openElement( 'form', $formParams ) .
801 Html::hidden( 'do', 'mark' ) .
802 Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
803 Html::hidden( 'revision', $page->getRevision() ) .
804 Html::hidden( 'target', $page->getTitle()->getPrefixedText() ) .
805 Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() )
806 );
807
808 $out->wrapWikiMsg( '==$1==', 'tpt-sections-oldnew' );
809
810 $diffOld = $this->msg( 'tpt-diff-old' )->escaped();
811 $diffNew = $this->msg( 'tpt-diff-new' )->escaped();
812 $hasChanges = false;
813
814 // Check whether page title was previously marked for translation.
815 // If the page is marked for translation the first time, default to checked.
816 $defaultChecked = $firstMark || $page->hasPageDisplayTitle();
817
818 $sourceLanguage = $this->languageFactory->getLanguage( $page->getSourceLanguageCode() );
819
820 foreach ( $sections as $s ) {
821 if ( $s->id === TranslatablePage::DISPLAY_TITLE_UNIT_ID ) {
822 // Set section type as new if title previously unchecked
823 $s->type = $defaultChecked ? $s->type : 'new';
824
825 // Checkbox for page title optional translation
826 $checkBox = new FieldLayout(
827 new CheckboxInputWidget( [
828 'name' => 'translatetitle',
829 'selected' => $defaultChecked,
830 ] ),
831 [
832 'label' => $this->msg( 'tpt-translate-title' )->text(),
833 'align' => 'inline',
834 'classes' => [ 'mw-tpt-m-vertical' ]
835 ]
836 );
837 $out->addHTML( $checkBox->toString() );
838 }
839
840 if ( $s->type === 'new' ) {
841 $hasChanges = true;
842 $name = $this->msg( 'tpt-section-new', $s->id )->escaped();
843 } else {
844 $name = $this->msg( 'tpt-section', $s->id )->escaped();
845 }
846
847 if ( $s->type === 'changed' ) {
848 $hasChanges = true;
849 $diff = new DifferenceEngine();
850 $diff->setTextLanguage( $sourceLanguage );
851 $diff->setReducedLineNumbers();
852
853 $oldContent = ContentHandler::makeContent( $s->getOldText(), $diff->getTitle() );
854 $newContent = ContentHandler::makeContent( $s->getText(), $diff->getTitle() );
855
856 $diff->setContent( $oldContent, $newContent );
857
858 $text = $diff->getDiff( $diffOld, $diffNew );
859 $diffOld = $diffNew = null;
860 $diff->showDiffStyle();
861
862 $id = "tpt-sect-{$s->id}-action-nofuzzy";
863 $checkLabel = new FieldLayout(
864 new CheckboxInputWidget( [
865 'name' => $id,
866 'selected' => false,
867 ] ),
868 [
869 'label' => $this->msg( 'tpt-action-nofuzzy' )->text(),
870 'align' => 'inline',
871 'classes' => [ 'mw-tpt-m-vertical' ]
872 ]
873 );
874 $text = $checkLabel->toString() . $text;
875 } else {
876 $text = Utilities::convertWhiteSpaceToHTML( $s->getText() );
877 }
878
879 # For changed text, the language is set by $diff->setTextLanguage()
880 $lang = $s->type === 'changed' ? null : $sourceLanguage;
881 $out->addHTML( MessageWebImporter::makeSectionElement(
882 $name,
883 $s->type,
884 $text,
885 $lang
886 ) );
887
888 foreach ( $s->getIssues() as $issue ) {
889 $severity = $issue->getSeverity();
890 if ( $severity === TranslationUnitIssue::WARNING ) {
891 $box = Html::warningBox( $this->msg( $issue )->escaped() );
892 } elseif ( $severity === TranslationUnitIssue::ERROR ) {
893 $box = Html::errorBox( $this->msg( $issue )->escaped() );
894 } else {
895 throw new MWException(
896 "Unknown severity: $severity for key: {$issue->getKey()}"
897 );
898 }
899
900 $out->addHTML( $box );
901 }
902 }
903
904 if ( $deletedUnits ) {
905 $hasChanges = true;
906 $out->wrapWikiMsg( '==$1==', 'tpt-sections-deleted' );
907
908 foreach ( $deletedUnits as $s ) {
909 $name = $this->msg( 'tpt-section-deleted', $s->id )->escaped();
910 $text = Utilities::convertWhiteSpaceToHTML( $s->getText() );
911 $out->addHTML( MessageWebImporter::makeSectionElement(
912 $name,
913 'deleted',
914 $text,
915 $sourceLanguage
916 ) );
917 }
918 }
919
920 // Display template changes if applicable
921 if ( $page->getMarkedTag() !== null ) {
922 $hasChanges = true;
923 $newTemplate = $parse->sourcePageTemplateForDiffs();
924 $oldPage = TranslatablePage::newFromRevision(
925 $page->getTitle(),
926 $page->getMarkedTag()
927 );
928 $oldTemplate = $this->translatablePageParser
929 ->parse( $oldPage->getText() )
930 ->sourcePageTemplateForDiffs();
931
932 if ( $oldTemplate !== $newTemplate ) {
933 $out->wrapWikiMsg( '==$1==', 'tpt-sections-template' );
934
935 $diff = new DifferenceEngine();
936 $diff->setTextLanguage( $sourceLanguage );
937
938 $oldContent = ContentHandler::makeContent( $oldTemplate, $diff->getTitle() );
939 $newContent = ContentHandler::makeContent( $newTemplate, $diff->getTitle() );
940
941 $diff->setContent( $oldContent, $newContent );
942
943 $text = $diff->getDiff(
944 $this->msg( 'tpt-diff-old' )->escaped(),
945 $this->msg( 'tpt-diff-new' )->escaped()
946 );
947 $diff->showDiffStyle();
948 $diff->setReducedLineNumbers();
949
950 $out->addHTML( Xml::tags( 'div', [], $text ) );
951 }
952 }
953
954 if ( !$hasChanges ) {
955 $out->wrapWikiMsg( Html::successBox( '$1' ), 'tpt-mark-nochanges' );
956 }
957
958 $this->priorityLanguagesForm( $page );
959
960 // If an existing page does not have the supportsTransclusion flag, keep the checkbox unchecked,
961 // If the page is being marked for translation for the first time, the checkbox can be checked
962 $this->templateTransclusionForm( $page->supportsTransclusion() ?? $firstMark );
963
965 $page->getMessageGroupId(), 'version', self::DEFAULT_SYNTAX_VERSION
966 );
967 $this->syntaxVersionForm( $version, $firstMark );
968
969 $submitButton = new FieldLayout(
970 new ButtonInputWidget( [
971 'label' => $this->msg( 'tpt-submit' )->text(),
972 'type' => 'submit',
973 'flags' => [ 'primary', 'progressive' ],
974 ] ),
975 [
976 'label' => null,
977 'align' => 'top',
978 ]
979 );
980
981 $out->addHTML( $submitButton->toString() );
982 $out->addHTML( '</form>' );
983 }
984
985 private function priorityLanguagesForm( TranslatablePage $page ): void {
986 $groupId = $page->getMessageGroupId();
987 $interfaceLanguage = $this->getLanguage()->getCode();
988 $storedLanguages = (string)TranslateMetadata::get( $groupId, 'prioritylangs' );
989 $default = $storedLanguages !== '' ? explode( ',', $storedLanguages ) : [];
990
991 $priorityReason = TranslateMetadata::get( $groupId, 'priorityreason' );
992 $priorityReason = $priorityReason !== false ? $priorityReason : '';
993
994 $form = new FieldsetLayout( [
995 'items' => [
996 new FieldLayout(
997 new LanguagesMultiselectWidget( [
998 'infusable' => true,
999 'name' => 'prioritylangs',
1000 'id' => 'mw-translate-SpecialPageTranslation-prioritylangs',
1001 'languages' => Utilities::getLanguageNames( $interfaceLanguage ),
1002 'default' => $default,
1003 ] ),
1004 [
1005 'label' => $this->msg( 'tpt-select-prioritylangs' )->text(),
1006 'align' => 'top',
1007 ]
1008 ),
1009 new FieldLayout(
1010 new CheckboxInputWidget( [
1011 'name' => 'forcelimit',
1012 'selected' => TranslateMetadata::get( $groupId, 'priorityforce' ) === 'on',
1013 ] ),
1014 [
1015 'label' => $this->msg( 'tpt-select-prioritylangs-force' )->text(),
1016 'align' => 'inline',
1017 ]
1018 ),
1019 new FieldLayout(
1020 new TextInputWidget( [
1021 'name' => 'priorityreason',
1022 'value' => $priorityReason
1023 ] ),
1024 [
1025 'label' => $this->msg( 'tpt-select-prioritylangs-reason' )->text(),
1026 'align' => 'top',
1027 ]
1028 ),
1029
1030 ],
1031 ] );
1032
1033 $this->getOutput()->wrapWikiMsg( '==$1==', 'tpt-sections-prioritylangs' );
1034 $this->getOutput()->addHTML( $form->toString() );
1035 }
1036
1037 private function syntaxVersionForm( string $version, bool $firstMark ): void {
1038 $out = $this->getOutput();
1039
1040 if ( $version === self::LATEST_SYNTAX_VERSION || $firstMark ) {
1041 return;
1042 }
1043
1044 $out->wrapWikiMsg( '==$1==', 'tpt-sections-syntaxversion' );
1045 $out->addWikiMsg(
1046 'tpt-syntaxversion-text',
1047 '<code>' . wfEscapeWikiText( '<span lang="en" dir="ltr">...</span>' ) . '</code>',
1048 '<code>' . wfEscapeWikiText( '<translate nowrap>...</translate>' ) . '</code>'
1049 );
1050
1051 $checkBox = new FieldLayout(
1052 new CheckboxInputWidget( [
1053 'name' => 'use-latest-syntax'
1054 ] ),
1055 [
1056 'label' => $out->msg( 'tpt-syntaxversion-label' )->text(),
1057 'align' => 'inline',
1058 ]
1059 );
1060
1061 $out->addHTML( $checkBox->toString() );
1062 }
1063
1064 private function templateTransclusionForm( bool $supportsTransclusion ): void {
1065 $out = $this->getOutput();
1066 $out->wrapWikiMsg( '==$1==', 'tpt-transclusion' );
1067
1068 $checkBox = new FieldLayout(
1069 new CheckboxInputWidget( [
1070 'name' => 'transclusion',
1071 'selected' => $supportsTransclusion
1072 ] ),
1073 [
1074 'label' => $out->msg( 'tpt-transclusion-label' )->text(),
1075 'align' => 'inline',
1076 ]
1077 );
1078
1079 $out->addHTML( $checkBox->toString() );
1080 }
1081
1098 protected function markForTranslation(
1099 TranslatablePage $page,
1100 ParserOutput $parse,
1101 array $sections,
1102 bool $updateVersion,
1103 bool $transclusion
1104 ) {
1105 // Add the section markers to the source page
1106 $wikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $page->getTitle() );
1107 $content = ContentHandler::makeContent(
1108 $parse->sourcePageTextForSaving(),
1109 $page->getTitle()
1110 );
1111
1112 $status = $wikiPage->doUserEditContent(
1113 $content,
1114 $this->getUser(),
1115 $this->msg( 'tpt-mark-summary' )->inContentLanguage()->text(),
1116 EDIT_FORCE_BOT | EDIT_UPDATE
1117 );
1118
1119 if ( !$status->isOK() ) {
1120 return [ 'tpt-edit-failed', $status->getWikiText() ];
1121 }
1122
1123 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1124 $newRevisionRecord = $status->value['revision-record'];
1125 // In theory, it is either null or RevisionRecord object,
1126 // not a RevisionRecord object with null id, but who knows
1127 if ( $newRevisionRecord instanceof RevisionRecord ) {
1128 $newRevisionId = $newRevisionRecord->getId();
1129 } else {
1130 $newRevisionId = null;
1131 }
1132
1133 // Probably a no-change edit, so no new revision was assigned.
1134 // Get the latest revision manually
1135 // Could also occur on the off chance $newRevisionRecord->getId() returns null
1136 if ( $newRevisionId === null ) {
1137 $newRevisionId = $page->getTitle()->getLatestRevID();
1138 }
1139
1140 $inserts = [];
1141 $changed = [];
1142 $groupId = $page->getMessageGroupId();
1143 $maxid = (int)TranslateMetadata::get( $groupId, 'maxid' );
1144
1145 $pageId = $page->getTitle()->getArticleID();
1147 foreach ( array_values( $sections ) as $index => $s ) {
1148 $maxid = max( $maxid, (int)$s->id );
1149 $changed[] = $s->id;
1150
1151 if ( $this->getRequest()->getCheck( "tpt-sect-{$s->id}-action-nofuzzy" ) ) {
1152 // UpdateTranslatablePageJob will only fuzzy when type is changed
1153 $s->type = 'old';
1154 }
1155
1156 $inserts[] = [
1157 'trs_page' => $pageId,
1158 'trs_key' => $s->id,
1159 'trs_text' => $s->getText(),
1160 'trs_order' => $index
1161 ];
1162 }
1163
1164 $dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
1165 $dbw->delete(
1166 'translate_sections',
1167 [ 'trs_page' => $page->getTitle()->getArticleID() ],
1168 __METHOD__
1169 );
1170 $dbw->insert( 'translate_sections', $inserts, __METHOD__ );
1171 TranslateMetadata::set( $groupId, 'maxid', $maxid );
1172 if ( $updateVersion ) {
1173 TranslateMetadata::set( $groupId, 'version', self::LATEST_SYNTAX_VERSION );
1174 }
1175
1176 $page->setTransclusion( $transclusion );
1177
1178 $page->addMarkedTag( $newRevisionId );
1179 MessageGroups::singleton()->recache();
1180
1181 // Store interim cache
1182 $group = $page->getMessageGroup();
1183 $newKeys = $group->makeGroupKeys( $changed );
1184 $this->messageIndex->storeInterim( $group, $newKeys );
1185
1186 $job = UpdateTranslatablePageJob::newFromPage( $page, $sections );
1187 $this->jobQueueGroup->push( $job );
1188
1189 $this->handlePriorityLanguages( $this->getRequest(), $page );
1190
1191 // Logging
1192 $entry = new ManualLogEntry( 'pagetranslation', 'mark' );
1193 $entry->setPerformer( $this->getUser() );
1194 $entry->setTarget( $page->getTitle() );
1195 $entry->setParameters( [
1196 'revision' => $newRevisionId,
1197 'changed' => count( $changed ),
1198 ] );
1199 $logid = $entry->insert();
1200 $entry->publish( $logid );
1201
1202 // Clear more caches
1203 $page->getTitle()->invalidateCache();
1204
1205 return false;
1206 }
1207
1213 protected function handlePriorityLanguages( WebRequest $request, TranslatablePage $page ): void {
1214 // Get the priority languages from the request
1215 // We've to do some extra work here because if JS is disabled, we will be getting
1216 // the values split by newline.
1217 $npLangs = rtrim( trim( $request->getVal( 'prioritylangs', '' ) ), ',' );
1218 $npLangs = implode( ',', explode( "\n", $npLangs ) );
1219 $npLangs = array_map( 'trim', explode( ',', $npLangs ) );
1220 $npLangs = array_unique( $npLangs );
1221
1222 $npForce = $request->getCheck( 'forcelimit' ) ? 'on' : 'off';
1223 $npReason = trim( $request->getText( 'priorityreason' ) );
1224
1225 // Remove invalid language codes.
1226 $languages = $this->languageNameUtils->getLanguageNames();
1227 foreach ( $npLangs as $index => $language ) {
1228 if ( !array_key_exists( $language, $languages ) ) {
1229 unset( $npLangs[$index] );
1230 }
1231 }
1232 $npLangs = implode( ',', $npLangs );
1233 if ( $npLangs === '' ) {
1234 $npLangs = false;
1235 $npForce = false;
1236 $npReason = false;
1237 }
1238
1239 $groupId = $page->getMessageGroupId();
1240 // old priority languages
1241 $opLangs = TranslateMetadata::get( $groupId, 'prioritylangs' );
1242 $opForce = TranslateMetadata::get( $groupId, 'priorityforce' );
1243 $opReason = TranslateMetadata::get( $groupId, 'priorityreason' );
1244
1245 TranslateMetadata::set( $groupId, 'prioritylangs', $npLangs );
1246 TranslateMetadata::set( $groupId, 'priorityforce', $npForce );
1247 TranslateMetadata::set( $groupId, 'priorityreason', $npReason );
1248
1249 if ( $opLangs !== $npLangs || $opForce !== $npForce || $opReason !== $npReason ) {
1250 $logComment = $npReason === false ? '' : $npReason;
1251 $params = [
1252 'languages' => $npLangs,
1253 'force' => $npForce,
1254 'reason' => $npReason,
1255 ];
1256
1257 $entry = new ManualLogEntry( 'pagetranslation', 'prioritylanguages' );
1258 $entry->setPerformer( $this->getUser() );
1259 $entry->setTarget( $page->getTitle() );
1260 $entry->setParameters( $params );
1261 $entry->setComment( $logComment );
1262 $logid = $entry->insert();
1263 $entry->publish( $logid );
1264 }
1265 }
1266
1267 private function getPageList( array $pages, string $type ): string {
1268 $items = [];
1269 $tagsTextCache = [];
1270
1271 $tagDiscouraged = $this->msg( 'tpt-tag-discouraged' )->escaped();
1272 $tagOldSyntax = $this->msg( 'tpt-tag-oldsyntax' )->escaped();
1273 $tagNoTransclusionSupport = $this->msg( 'tpt-tag-no-transclusion-support' )->escaped();
1274
1275 foreach ( $pages as $page ) {
1276 $link = $this->getLinkRenderer()->makeKnownLink( $page['title'] );
1277 $acts = $this->actionLinks( $page, $type );
1278 $tags = [];
1279 if ( $page['discouraged'] ) {
1280 $tags[] = $tagDiscouraged;
1281 }
1282 if ( $type !== 'proposed' ) {
1283 if ( $page['version'] !== self::LATEST_SYNTAX_VERSION ) {
1284 $tags[] = $tagOldSyntax;
1285 }
1286
1287 if ( $page['transclusion'] !== '1' ) {
1288 $tags[] = $tagNoTransclusionSupport;
1289 }
1290 }
1291
1292 $tagList = '';
1293 if ( $tags ) {
1294 // Performance optimization to avoid calling $this->msg in a loop
1295 $tagsKey = implode( '', $tags );
1296 $tagsTextCache[$tagsKey] = $tagsTextCache[$tagsKey] ??
1297 $this->msg( 'parentheses' )
1298 ->rawParams( $this->getLanguage()->pipeList( $tags ) )
1299 ->escaped();
1300
1301 $tagList = Html::rawElement(
1302 'span',
1303 [ 'class' => 'mw-tpt-actions' ],
1304 $tagsTextCache[$tagsKey]
1305 );
1306 }
1307
1308 $items[] = "<li>$link $tagList $acts</li>";
1309 }
1310
1311 return '<ol>' . implode( "", $items ) . '</ol>';
1312 }
1313}
Factory class for accessing message groups individually by id or all of them as a list.
Class to manage revision tags for translatable bundles.
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...
markForTranslation(TranslatablePage $page, ParserOutput $parse, array $sections, bool $updateVersion, bool $transclusion)
This function does the heavy duty of marking a page.
Represents a parsing output produced by TranslatablePageParser.
sourcePageTextForSaving()
Returns the source page with translation unit markers.
Generates ParserOutput from text or removes all tags from a text.
Mixed bag of methods related to translatable pages.
static getMessageGroupIdFromTitle(Title $title)
Constructs MessageGroup id for any title.
static newFromRevision(Title $title, int $revision)
Constructs a translatable page from given revision.
addMarkedTag(int $revision, array $value=null)
Adds a tag which indicates that this page is suitable for translation.
static newFromTitle(Title $title)
Constructs a translatable page from title.
unmarkTranslatablePage()
Removes all page translation feature data from the database.
getMessageGroup()
Returns MessageGroup used for translating this page.
Essentially random collection of helper functions, similar to GlobalFunctions.php.
Definition Utilities.php:30
Job for rebuilding message group stats.
static newRefreshGroupsJob(array $messageGroupIds)
Force updating of message group stats for given groups.
Creates a database of keys in all groups, so that namespace and key can be used to get the groups the...
static getWithDefaultValue(string $group, string $key, string $defaultValue)
Get a metadata value for the given group and key.
static get( $group, $key)
Get a metadata value for the given group and key.