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