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