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