Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
TranslatablePageMarker.php
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\PageTranslation;
5
6use EmptyIterator;
7use LogicException;
8use MediaWiki\CommentStore\CommentStoreComment;
9use MediaWiki\Content\ContentHandler;
17use MediaWiki\JobQueue\JobQueueGroup;
18use MediaWiki\Language\FormatterFactory;
19use MediaWiki\Language\MessageLocalizer;
20use MediaWiki\Language\RawMessage;
21use MediaWiki\Linker\LinkRenderer;
22use MediaWiki\Logging\ManualLogEntry;
23use MediaWiki\Message\Message;
24use MediaWiki\Page\PageRecord;
25use MediaWiki\Page\WikiPageFactory;
26use MediaWiki\Permissions\Authority;
27use MediaWiki\RecentChanges\RecentChange;
28use MediaWiki\Revision\SlotRecord;
29use MediaWiki\Status\Status;
30use MediaWiki\Storage\PageUpdater;
31use MediaWiki\Title\MalformedTitleException;
32use MediaWiki\Title\Title;
33use MediaWiki\Title\TitleFormatter;
34use MediaWiki\Title\TitleParser;
35use MediaWiki\User\User;
36use MediaWiki\User\UserIdentity;
37use Wikimedia\Message\ListType;
38use Wikimedia\Rdbms\IConnectionProvider;
40
46 public const LATEST_SYNTAX_VERSION = '2';
47 public const DEFAULT_SYNTAX_VERSION = '1';
48
49 private IConnectionProvider $dbProvider;
50 private JobQueueGroup $jobQueueGroup;
51 private LinkRenderer $linkRenderer;
52 private MessageGroups $messageGroups;
53 private MessageIndex $messageIndex;
54 private TitleFormatter $titleFormatter;
55 private TitleParser $titleParser;
56 private TranslatablePageParser $translatablePageParser;
57 private TranslatablePageStore $translatablePageStore;
58 private TranslatablePageStateStore $translatablePageStateStore;
59 private TranslationUnitStoreFactory $translationUnitStoreFactory;
60 private MessageGroupMetadata $messageGroupMetadata;
61 private WikiPageFactory $wikiPageFactory;
62 private TranslatablePageView $translatablePageView;
63 private MessageGroupSubscription $messageGroupSubscription;
64 private FormatterFactory $formatterFactory;
65 private HookRunner $hookRunner;
66
67 public function __construct(
68 IConnectionProvider $dbProvider,
69 JobQueueGroup $jobQueueGroup,
70 LinkRenderer $linkRenderer,
71 MessageGroups $messageGroups,
72 MessageIndex $messageIndex,
73 TitleFormatter $titleFormatter,
74 TitleParser $titleParser,
75 TranslatablePageParser $translatablePageParser,
76 TranslatablePageStore $translatablePageStore,
77 TranslatablePageStateStore $translatablePageStateStore,
78 TranslationUnitStoreFactory $translationUnitStoreFactory,
79 MessageGroupMetadata $messageGroupMetadata,
80 WikiPageFactory $wikiPageFactory,
81 TranslatablePageView $translatablePageView,
82 MessageGroupSubscription $messageGroupSubscription,
83 FormatterFactory $formatterFactory,
84 HookRunner $hookRunner,
85 ) {
86 $this->dbProvider = $dbProvider;
87 $this->jobQueueGroup = $jobQueueGroup;
88 $this->linkRenderer = $linkRenderer;
89 $this->messageIndex = $messageIndex;
90 $this->titleFormatter = $titleFormatter;
91 $this->titleParser = $titleParser;
92 $this->translatablePageParser = $translatablePageParser;
93 $this->translatablePageStore = $translatablePageStore;
94 $this->translatablePageStateStore = $translatablePageStateStore;
95 $this->translationUnitStoreFactory = $translationUnitStoreFactory;
96 $this->wikiPageFactory = $wikiPageFactory;
97 $this->messageGroups = $messageGroups;
98 $this->messageGroupMetadata = $messageGroupMetadata;
99 $this->translatablePageView = $translatablePageView;
100 $this->messageGroupSubscription = $messageGroupSubscription;
101 $this->formatterFactory = $formatterFactory;
102 $this->hookRunner = $hookRunner;
103 }
104
113 public function unmarkPage(
114 TranslatablePage $page,
115 User $user,
116 MessageLocalizer $localizer,
117 bool $removeMarkup
118 ): void {
119 if ( $removeMarkup ) {
120 $pageTitle = $page->getTitle();
121 $content = ContentHandler::makeContent( $page->getStrippedSourcePageText(), $pageTitle );
122
123 $wikiPage = $this->wikiPageFactory->newFromTitle( $pageTitle );
124 $updater = $wikiPage->newPageUpdater( $user )
125 ->setContent( SlotRecord::MAIN, $content );
126 $summary = CommentStoreComment::newUnsavedComment(
127 Message::newFromKey( 'tpt-unlink-summary' )->inContentLanguage()->text()
128 );
129
130 if ( $user->authorizeWrite( 'autopatrol', $pageTitle ) ) {
131 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
132 }
133 $updater->saveRevision( $summary, EDIT_FORCE_BOT | EDIT_UPDATE );
134 $this->throwIfEditFailed( $updater, $localizer );
135 }
136
137 $this->translatablePageStore->unmark( $page->getPageIdentity() );
138
139 $entry = new ManualLogEntry( 'pagetranslation', 'unmark' );
140 $entry->setPerformer( $user );
141 $entry->setTarget( $page->getPageIdentity() );
142 $logId = $entry->insert();
143 $entry->publish( $logId );
144 }
145
160 public function getMarkOperation(
161 PageRecord $page,
162 ?int $revision,
163 ?bool $translateTitle
165 $latestRevID = $page->getLatest();
166 if ( $revision === null ) {
167 // Get the latest revision
168 $revision = $latestRevID;
169 }
170
171 // This also catches the case where revision does not belong to the title
172 if ( $revision !== $latestRevID ) {
173 // We do want to notify the reviewer if the underlying page changes during review
174 $link = $this->linkRenderer->makeKnownLink(
175 $page,
176 (string)$revision,
177 [],
178 [ 'oldid' => (string)$revision ]
179 );
181 'tpt-oldrevision',
182 $this->titleFormatter->getPrefixedText( $page ),
183 Message::rawParam( $link )
184 ] );
185 }
186
187 // newFromRevision never fails, but getReadyTag might fail if revision does not belong
188 // to the page (checked above)
189 $translatablePage = TranslatablePage::newFromRevision( $page, $revision );
190 if ( $translatablePage->getReadyTag() !== $latestRevID ) {
191 throw new TranslatablePageMarkException( [
192 'tpt-notsuitable',
193 $this->titleFormatter->getPrefixedText( $page ),
194 Message::plaintextParam( '<translate>' )
195 ] );
196 }
197
198 // Check whether page title was previously marked for translation.
199 // If the page is marked for translation the first time, default to
200 // allowing title translation, unless the page is a template. T305240
201 $isFirstMark = $translatablePage->getMarkedTag() === null;
202 if ( $translateTitle === null ) {
203 $isTemplateNamespace = $translatablePage->getTitle()->inNamespace( NS_TEMPLATE );
204 $translateTitle = ( $isFirstMark && !$isTemplateNamespace ) || $translatablePage->hasPageDisplayTitle();
205 }
206
207 $parserOutput = $this->translatablePageParser->parse( $translatablePage->getText() );
208 [ $units, $deletedUnits ] = $this->prepareTranslationUnits( $translatablePage, $parserOutput );
209
210 // Give extensions a chance to disable title translation.
211 $reason = null;
212 $defaultState = $translateTitle ?
213 TranslateTitleEnum::DEFAULT_CHECKED :
214 TranslateTitleEnum::DEFAULT_UNCHECKED;
215 $this->hookRunner->onTranslateTitlePageTranslation(
216 $defaultState,
217 $translatablePage->getPageIdentity(),
218 $reason
219 );
220
221 $unitValidationStatus = $this->validateUnitNames(
222 $translatablePage,
223 $units,
224 $defaultState !== TranslateTitleEnum::DISABLED && $translateTitle
225 );
226
227 return new TranslatablePageMarkOperation(
228 $translatablePage,
229 $parserOutput,
230 $units,
231 $deletedUnits,
232 $isFirstMark,
233 $unitValidationStatus,
234 $defaultState,
235 $reason
236 );
237 }
238
249 private function validateUnitNames(
250 TranslatablePage $page,
251 array $units,
252 bool $includePageDisplayTitle
253 ): Status {
254 $usedNames = [];
255 $status = Status::newGood();
256 $ic = preg_quote( TranslationUnit::UNIT_MARKER_INVALID_CHARS, '~' );
257 foreach ( $units as $key => $s ) {
258 $unitStatus = Status::newGood();
259 if ( $includePageDisplayTitle || $key !== TranslatablePage::DISPLAY_TITLE_UNIT_ID ) {
260 // xx-yyyyyyyyyy represents a long language code. 2 more characters than nl-informal which
261 // is the longest non-redirect language code in language-data
262 $pageTitle = $this->titleFormatter->getPrefixedText( $page->getPageIdentity() );
263 $longestUnitTitle = "Translations:$pageTitle/{$s->id}/xx-yyyyyyyyyy";
264 try {
265 $this->titleParser->parseTitle( $longestUnitTitle );
266 } catch ( MalformedTitleException $e ) {
267 if ( $e->getErrorMessage() === 'title-invalid-too-long' ) {
268 $unitStatus->fatal(
269 'tpt-unit-title-too-long',
270 $s->id,
271 Message::numParam( strlen( $longestUnitTitle ) ),
272 $e->getErrorMessageParameters()[0],
273 $pageTitle
274 );
275 } else {
276 $unitStatus->fatal( 'tpt-unit-title-invalid', $s->id, $e->getMessageObject() );
277 }
278 }
279
280 // Only perform custom validation if the TitleParser validation passed
281 if ( $unitStatus->isGood() && preg_match( "~[$ic]~", $s->id ) ) {
282 $unitStatus->fatal( 'tpt-invalid', $s->id );
283 }
284 }
285
286 // We need to do checks for both new and existing units. Someone might have tampered with the
287 // page source adding duplicate or invalid markers.
288 if ( isset( $usedNames[$s->id] ) ) {
289 // If the same ID is used three or more times, the same
290 // error will be added more than once, but that's okay,
291 // Status::fatal will deduplicate
292 $unitStatus->fatal( 'tpt-duplicate', $s->id );
293 }
294 $usedNames[$s->id] = true;
295
296 $status->merge( $unitStatus );
297 }
298
299 return $status;
300 }
301
321 public function markForTranslation(
323 TranslatablePageSettings $pageSettings,
324 MessageLocalizer $localizer,
325 User $user
326 ): int {
327 if ( !$operation->isValid() ) {
328 throw new LogicException( 'Trying to mark a page for translation that is not valid' );
329 }
330
331 $page = $operation->getPage();
332 $title = $page->getTitle();
333 $newRevisionId = $this->updateSectionMarkers( $page, $user, $localizer, $operation );
334 // Probably a no-change edit, so no new revision was assigned. Get the latest revision manually
335 // Could also occur on the off chance $newRevisionRecord->getId() returns null
336 $newRevisionId ??= $title->getLatestRevID();
337
338 $inserts = [];
339 $changed = [];
340 $groupId = $page->getMessageGroupId();
341 $maxId = (int)$this->messageGroupMetadata->get( $groupId, 'maxid' );
342
343 $pageId = $title->getId();
344 if ( $pageSettings->shouldTranslateTitle() &&
345 $operation->titleTranslationState === TranslateTitleEnum::DISABLED
346 ) {
347 if ( $operation->titleTranslationStateReason ) {
348 throw new TranslatablePageMarkException( new RawMessage( $operation->titleTranslationStateReason ) );
349 } else {
350 throw new TranslatablePageMarkException( 'tpt-translate-title-disabled' );
351 }
352 }
353 $sections = $pageSettings->shouldTranslateTitle()
354 ? $operation->getUnits()
355 : array_filter(
356 $operation->getUnits(),
357 static fn ( TranslationUnit $s ) => $s->id !== TranslatablePage::DISPLAY_TITLE_UNIT_ID
358 );
359
360 foreach ( array_values( $sections ) as $index => $s ) {
361 $maxId = max( $maxId, (int)$s->id );
362 $changed[] = $s->id;
363
364 if ( in_array( $s->id, $pageSettings->getNoFuzzyUnits(), true ) ) {
365 // UpdateTranslatablePageJob will only fuzzy when type is changed
366 $s->type = 'old';
367 }
368
369 $inserts[] = [
370 'trs_page' => $pageId,
371 'trs_key' => $s->id,
372 'trs_text' => $s->getText(),
373 'trs_order' => $index
374 ];
375 }
376
377 $dbw = $this->dbProvider->getPrimaryDatabase();
378 $dbw->newDeleteQueryBuilder()
379 ->deleteFrom( 'translate_sections' )
380 ->where( [ 'trs_page' => $pageId ] )
381 ->caller( __METHOD__ )
382 ->execute();
383
384 if ( $inserts ) {
385 $dbw->newInsertQueryBuilder()
386 ->insertInto( 'translate_sections' )
387 ->rows( $inserts )
388 ->caller( __METHOD__ )
389 ->execute();
390 }
391
392 $this->saveMetadata( $operation, $pageSettings, $maxId, $user );
393
394 $page->addMarkedTag( $newRevisionId );
395
396 if ( $this->translatablePageView->isTranslationBannerNamespaceConfigured() ) {
397 $this->translatablePageStateStore->remove( $page->getPageIdentity() );
398 }
399
400 // TODO: Ideally we would only invalidate translatable page message group cache
401 $this->messageGroups->recache();
402
403 $group = new WikiPageMessageGroup( $groupId, $title );
404 $newKeys = $group->makeGroupKeys( $changed );
405 // Interim cache is temporary cache to make new message groups keys known
406 // until MessageIndex is rebuilt (which can take a long time)
407 $this->messageIndex->storeInterim( $group, $newKeys );
408
409 $job = UpdateTranslatablePageJob::newFromPage( $page, $sections );
410 $this->jobQueueGroup->push( $job );
411
412 // Logging
413 $entry = new ManualLogEntry( 'pagetranslation', 'mark' );
414 $entry->setPerformer( $user );
415 $entry->setTarget( $title );
416 $entry->setParameters( [
417 'revision' => $newRevisionId,
418 'changed' => count( $changed ),
419 ] );
420 $logId = $entry->insert();
421 $entry->publish( $logId );
422
423 // Clear more caches
424 $title->invalidateCache();
425
426 $this->sendNotifications( $sections, $group, $groupId, $operation->isFirstMark() );
427
428 return count( $sections );
429 }
430
431 private function saveMetadata(
432 TranslatablePageMarkOperation $operation,
433 TranslatablePageSettings $pageSettings,
434 int $maxId,
435 UserIdentity $user
436 ): void {
437 $page = $operation->getPage();
438 $groupId = $page->getMessageGroupId();
439
440 $this->messageGroupMetadata->set( $groupId, 'maxid', (string)$maxId );
441 if ( $pageSettings->shouldForceLatestSyntaxVersion() || $operation->isFirstMark() ) {
442 $this->messageGroupMetadata->set( $groupId, 'version', self::LATEST_SYNTAX_VERSION );
443 }
444
445 $this->messageGroupMetadata->set(
446 $groupId,
447 'transclusion',
448 $pageSettings->shouldEnableTransclusion() ? '1' : '0'
449 );
450
451 $this->handlePriorityLanguages( $operation->getPage(), $pageSettings, $user );
452 }
453
454 public function validatePriorityLanguages( array $codes ): Status {
455 $knownCodes = array_keys( Utilities::getLanguageNames( 'en' ) );
456 $invalidCodes = array_diff( $codes, $knownCodes );
457 if ( $invalidCodes ) {
458 return Status::newFatal(
459 'tpt-priority-languages-invalid',
460 Message::numParam( count( $invalidCodes ) ),
461 Message::listParam( array_values( $invalidCodes ), ListType::AND )
462 );
463 }
464 return Status::newGood();
465 }
466
467 private function handlePriorityLanguages(
468 TranslatablePage $page,
469 TranslatablePageSettings $pageSettings,
470 UserIdentity $user
471 ): void {
472 $languages = $pageSettings->getPriorityLanguages() ?
473 implode( ',', $pageSettings->getPriorityLanguages() ) :
474 false;
475 $force = $pageSettings->shouldForcePriorityLanguage() ? 'on' : false;
476 $hasPriorityConfig = $languages || $force;
477
478 // We use the reason if priority force and / or priority languages are set
479 // Otherwise just a reason doesn't make sense
480 if ( $hasPriorityConfig && $pageSettings->getPriorityLanguageComment() !== '' ) {
481 $reason = $pageSettings->getPriorityLanguageComment();
482 } else {
483 $reason = false;
484 }
485
486 $groupId = $page->getMessageGroupId();
487 // old metadata
488 $opLanguages = $this->messageGroupMetadata->get( $groupId, 'prioritylangs' );
489 $opForce = $this->messageGroupMetadata->get( $groupId, 'priorityforce' );
490 $opReason = $this->messageGroupMetadata->get( $groupId, 'priorityreason' );
491
492 $this->messageGroupMetadata->set( $groupId, 'prioritylangs', $languages );
493 $this->messageGroupMetadata->set( $groupId, 'priorityforce', $force );
494 $this->messageGroupMetadata->set( $groupId, 'priorityreason', $reason );
495
496 if (
497 $opLanguages !== $languages ||
498 // Since 2024.04, we started storing false instead of 'off' to avoid additional storage
499 // Remove after 2024.07 MLEB release
500 ( $opForce !== $force && !( $force === false && $opForce === 'off' ) ) ||
501 // Since 2024.04, empty reason values are no longer stored.
502 // Remove casting to string after 2024.07 MLEB release
503 ( (string)$opReason !== (string)$reason )
504 ) {
505 $logComment = $reason === false ? '' : $reason;
506 $params = [
507 'languages' => $languages,
508 'force' => $force,
509 'reason' => $reason,
510 ];
511
512 $entry = new ManualLogEntry( 'pagetranslation', 'prioritylanguages' );
513 $entry->setPerformer( $user );
514 $entry->setTarget( $page->getTitle() );
515 $entry->setParameters( $params );
516 $entry->setComment( $logComment );
517 $logId = $entry->insert();
518 $entry->publish( $logId );
519 }
520 }
521
522 private function prepareTranslationUnits( TranslatablePage $page, ParserOutput $parserOutput ): array {
523 $highest = (int)$this->messageGroupMetadata->get( $page->getMessageGroupId(), 'maxid' );
524
525 $store = $this->translationUnitStoreFactory->getReader( $page->getPageIdentity() );
526 $storedUnits = $store->getUnits();
527
528 // Prepend the display title unit, which is not part of the page contents
529 $displayTitle = new TranslationUnit(
530 $this->titleFormatter->getPrefixedText( $page->getPageIdentity() ),
531 TranslatablePage::DISPLAY_TITLE_UNIT_ID
532 );
533
534 $units = [ TranslatablePage::DISPLAY_TITLE_UNIT_ID => $displayTitle ] + $parserOutput->units();
535
536 // Figure out the largest used translation unit id
537 foreach ( array_keys( $storedUnits ) as $key ) {
538 $highest = max( $highest, (int)$key );
539 }
540 foreach ( $units as $_ ) {
541 $highest = max( $highest, (int)$_->id );
542 }
543
544 foreach ( $units as $s ) {
545 $s->type = 'old';
546
547 if ( $s->id === TranslationUnit::NEW_UNIT_ID ) {
548 $s->type = 'new';
549 $s->id = (string)( ++$highest );
550 } else {
551 if ( isset( $storedUnits[$s->id] ) ) {
552 $storedText = $storedUnits[$s->id]->text;
553 if ( $s->text !== $storedText ) {
554 $s->type = 'changed';
555 $s->oldText = $storedText;
556 }
557 } else {
558 $s->type = 'new';
559 }
560 }
561 }
562
563 // Figure out which units were deleted by removing the still existing units
564 $deletedUnits = $storedUnits;
565 foreach ( $units as $s ) {
566 unset( $deletedUnits[$s->id] );
567 }
568
569 return [ $units, $deletedUnits ];
570 }
571
572 private function updateSectionMarkers(
573 TranslatablePage $page,
574 Authority $authority,
575 MessageLocalizer $localizer,
576 TranslatablePageMarkOperation $operation
577 ): ?int {
578 $pageUpdater = $this->wikiPageFactory->newFromTitle( $page->getPageIdentity() )
579 ->newPageUpdater( $authority->getUser() );
580 $content = ContentHandler::makeContent(
581 $operation->getParserOutput()->sourcePageTextForSaving(),
582 $page->getTitle()
583 );
584 $comment = CommentStoreComment::newUnsavedComment(
585 Message::newFromKey( 'tpt-mark-summary' )->inContentLanguage()->text()
586 );
587
588 $pageUpdater->setContent( SlotRecord::MAIN, $content );
589 if ( $authority->authorizeWrite( 'autopatrol', $page->getTitle() ) ) {
590 $pageUpdater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
591 }
592 $newRevisionRecord = $pageUpdater->saveRevision( $comment, EDIT_FORCE_BOT | EDIT_UPDATE );
593
594 $this->throwIfEditFailed( $pageUpdater, $localizer );
595
596 return $newRevisionRecord !== null ? $newRevisionRecord->getId() : null;
597 }
598
600 private function throwIfEditFailed( PageUpdater $pageUpdater, MessageLocalizer $messageLocalizer ): void {
601 $status = $pageUpdater->getStatus();
602 if ( !$status->isOK() ) {
603 throw new TranslatablePageMarkException(
604 [
605 'tpt-edit-failed',
606 $this->formatterFactory->getStatusFormatter( $messageLocalizer )->getMessage( $status ),
607 ]
608 );
609 }
610 }
611
618 public function sendNotifications(
619 array $sections,
621 string $groupId,
622 bool $isFirstMark
623 ): void {
624 $changedMessages = array_filter( $sections, static function ( $section ) {
625 return $section->type !== 'old';
626 } );
627
628 if ( !$changedMessages ) {
629 return;
630 }
631
632 if ( $isFirstMark ) {
633 $subscribers = $this->messageGroupSubscription->getGroupSubscribers( $groupId );
634 if ( $subscribers instanceof EmptyIterator ) {
635 // A non-translatable page may have subscribers if it was marked for translation before, or
636 // if it was subscribed to from Special:ManageMessageGroupSubscriptions/raw.
637 // This page is being marked for the first time, and has no subscribers, so no need to
638 // send notifications
639 return;
640 }
641 }
642
643 $code = $group->getSourceLanguage();
644 $pageTitle = $group->getTitle();
645
646 foreach ( $changedMessages as $message ) {
647 $messageTitle = Title::makeTitle( NS_TRANSLATIONS, "$pageTitle/$message->id/$code" );
648 $this->messageGroupSubscription->queueMessage(
649 $messageTitle,
650 $message->type === 'new'
651 ? MessageGroupSubscription::STATE_ADDED
652 : MessageGroupSubscription::STATE_UPDATED,
653 $groupId
654 );
655 }
656 $this->messageGroupSubscription->queueNotificationJob();
657 }
658}
Hook runner for the Translate extension.
Manage user subscriptions to message groups and trigger notifications.
Factory class for accessing message groups individually by id or all of them as a list.
Creates a database of keys in all groups, so that namespace and key can be used to get the groups the...
Offers functionality for reading and updating Translate group related metadata.
Exception thrown when TranslatablePageMarker is unable to unmark a page for translation.
This class encapsulates the information / state needed to mark a page for translation.
Service to mark/unmark pages from translation and perform related validations.
unmarkPage(TranslatablePage $page, User $user, MessageLocalizer $localizer, bool $removeMarkup)
Remove a page from translation.
markForTranslation(TranslatablePageMarkOperation $operation, TranslatablePageSettings $pageSettings, MessageLocalizer $localizer, User $user)
This function does the heavy duty of marking a page.
getMarkOperation(PageRecord $page, ?int $revision, ?bool $translateTitle)
Parse the given page and create a new MarkPageOperation with the page and the given revision if the r...
sendNotifications(array $sections, WikiPageMessageGroup $group, string $groupId, bool $isFirstMark)
Generates ParserOutput from text or removes all tags from a text.
Value object containing user configurable settings when marking a page for translation.
Logic and code to generate various aspects related to how translatable pages are displayed.
Mixed bag of methods related to translatable pages.
static newFromRevision(PageIdentity $page, int $revision)
Constructs a translatable page from given revision.
Essentially random collection of helper functions, similar to GlobalFunctions.php.
Definition Utilities.php:30
Wraps the translatable page sections into a message group.