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