46 public const LATEST_SYNTAX_VERSION =
'2';
47 public const DEFAULT_SYNTAX_VERSION =
'1';
49 private IConnectionProvider $dbProvider;
50 private JobQueueGroup $jobQueueGroup;
51 private LinkRenderer $linkRenderer;
54 private TitleFormatter $titleFormatter;
55 private TitleParser $titleParser;
61 private WikiPageFactory $wikiPageFactory;
64 private FormatterFactory $formatterFactory;
67 public function __construct(
68 IConnectionProvider $dbProvider,
69 JobQueueGroup $jobQueueGroup,
70 LinkRenderer $linkRenderer,
73 TitleFormatter $titleFormatter,
74 TitleParser $titleParser,
80 WikiPageFactory $wikiPageFactory,
83 FormatterFactory $formatterFactory,
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;
116 MessageLocalizer $localizer,
119 if ( $removeMarkup ) {
121 $content = ContentHandler::makeContent( $page->getStrippedSourcePageText(), $pageTitle );
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()
130 if ( $user->authorizeWrite(
'autopatrol', $pageTitle ) ) {
131 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
133 $updater->saveRevision( $summary, EDIT_FORCE_BOT | EDIT_UPDATE );
134 $this->throwIfEditFailed( $updater, $localizer );
137 $this->translatablePageStore->unmark( $page->getPageIdentity() );
139 $entry =
new ManualLogEntry(
'pagetranslation',
'unmark' );
140 $entry->setPerformer( $user );
141 $entry->setTarget( $page->getPageIdentity() );
142 $logId = $entry->insert();
143 $entry->publish( $logId );
163 ?
bool $translateTitle
165 $latestRevID = $page->getLatest();
166 if ( $revision ===
null ) {
168 $revision = $latestRevID;
172 if ( $revision !== $latestRevID ) {
174 $link = $this->linkRenderer->makeKnownLink(
178 [
'oldid' => (
string)$revision ]
182 $this->titleFormatter->getPrefixedText( $page ),
183 Message::rawParam( $link )
190 if ( $translatablePage->getReadyTag() !== $latestRevID ) {
191 throw new TranslatablePageMarkException( [
193 $this->titleFormatter->getPrefixedText( $page ),
194 Message::plaintextParam(
'<translate>' )
201 $isFirstMark = $translatablePage->getMarkedTag() ===
null;
202 if ( $translateTitle ===
null ) {
203 $isTemplateNamespace = $translatablePage->getTitle()->inNamespace( NS_TEMPLATE );
204 $translateTitle = ( $isFirstMark && !$isTemplateNamespace ) || $translatablePage->hasPageDisplayTitle();
207 $parserOutput = $this->translatablePageParser->parse( $translatablePage->getText() );
208 [ $units, $deletedUnits ] = $this->prepareTranslationUnits( $translatablePage, $parserOutput );
212 $defaultState = $translateTitle ?
213 TranslateTitleEnum::DEFAULT_CHECKED :
214 TranslateTitleEnum::DEFAULT_UNCHECKED;
215 $this->hookRunner->onTranslateTitlePageTranslation(
217 $translatablePage->getPageIdentity(),
221 $unitValidationStatus = $this->validateUnitNames(
224 $defaultState !== TranslateTitleEnum::DISABLED && $translateTitle
227 return new TranslatablePageMarkOperation(
233 $unitValidationStatus,
249 private function validateUnitNames(
250 TranslatablePage $page,
252 bool $includePageDisplayTitle
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 ) {
262 $pageTitle = $this->titleFormatter->getPrefixedText( $page->getPageIdentity() );
263 $longestUnitTitle =
"Translations:$pageTitle/{$s->id}/xx-yyyyyyyyyy";
265 $this->titleParser->parseTitle( $longestUnitTitle );
266 }
catch ( MalformedTitleException $e ) {
267 if ( $e->getErrorMessage() ===
'title-invalid-too-long' ) {
269 'tpt-unit-title-too-long',
271 Message::numParam( strlen( $longestUnitTitle ) ),
272 $e->getErrorMessageParameters()[0],
276 $unitStatus->fatal(
'tpt-unit-title-invalid', $s->id, $e->getMessageObject() );
281 if ( $unitStatus->isGood() && preg_match(
"~[$ic]~", $s->id ) ) {
282 $unitStatus->fatal(
'tpt-invalid', $s->id );
288 if ( isset( $usedNames[$s->id] ) ) {
292 $unitStatus->fatal(
'tpt-duplicate', $s->id );
294 $usedNames[$s->id] =
true;
296 $status->merge( $unitStatus );
324 MessageLocalizer $localizer,
327 if ( !$operation->isValid() ) {
328 throw new LogicException(
'Trying to mark a page for translation that is not valid' );
331 $page = $operation->getPage();
332 $title = $page->getTitle();
333 $newRevisionId = $this->updateSectionMarkers( $page, $user, $localizer, $operation );
336 $newRevisionId ??= $title->getLatestRevID();
340 $groupId = $page->getMessageGroupId();
341 $maxId = (int)$this->messageGroupMetadata->get( $groupId,
'maxid' );
343 $pageId = $title->getId();
344 if ( $pageSettings->shouldTranslateTitle() &&
345 $operation->titleTranslationState === TranslateTitleEnum::DISABLED
347 if ( $operation->titleTranslationStateReason ) {
350 throw new TranslatablePageMarkException(
'tpt-translate-title-disabled' );
353 $sections = $pageSettings->shouldTranslateTitle()
354 ? $operation->getUnits()
356 $operation->getUnits(),
357 static fn ( TranslationUnit $s ) => $s->id !== TranslatablePage::DISPLAY_TITLE_UNIT_ID
360 foreach ( array_values( $sections ) as $index => $s ) {
361 $maxId = max( $maxId, (
int)$s->id );
364 if ( in_array( $s->id, $pageSettings->getNoFuzzyUnits(),
true ) ) {
370 'trs_page' => $pageId,
372 'trs_text' => $s->getText(),
373 'trs_order' => $index
377 $dbw = $this->dbProvider->getPrimaryDatabase();
378 $dbw->newDeleteQueryBuilder()
379 ->deleteFrom(
'translate_sections' )
380 ->where( [
'trs_page' => $pageId ] )
381 ->caller( __METHOD__ )
385 $dbw->newInsertQueryBuilder()
386 ->insertInto(
'translate_sections' )
388 ->caller( __METHOD__ )
392 $this->saveMetadata( $operation, $pageSettings, $maxId, $user );
394 $page->addMarkedTag( $newRevisionId );
396 if ( $this->translatablePageView->isTranslationBannerNamespaceConfigured() ) {
397 $this->translatablePageStateStore->remove( $page->getPageIdentity() );
401 $this->messageGroups->recache();
404 $newKeys = $group->makeGroupKeys( $changed );
407 $this->messageIndex->storeInterim( $group, $newKeys );
409 $job = UpdateTranslatablePageJob::newFromPage( $page, $sections );
410 $this->jobQueueGroup->push( $job );
413 $entry =
new ManualLogEntry(
'pagetranslation',
'mark' );
414 $entry->setPerformer( $user );
415 $entry->setTarget( $title );
416 $entry->setParameters( [
417 'revision' => $newRevisionId,
418 'changed' => count( $changed ),
420 $logId = $entry->insert();
421 $entry->publish( $logId );
424 $title->invalidateCache();
426 $this->sendNotifications( $sections, $group, $groupId, $operation->isFirstMark() );
428 return count( $sections );
431 private function saveMetadata(
432 TranslatablePageMarkOperation $operation,
433 TranslatablePageSettings $pageSettings,
437 $page = $operation->getPage();
438 $groupId = $page->getMessageGroupId();
440 $this->messageGroupMetadata->set( $groupId,
'maxid', (
string)$maxId );
441 if ( $pageSettings->shouldForceLatestSyntaxVersion() || $operation->isFirstMark() ) {
442 $this->messageGroupMetadata->set( $groupId,
'version', self::LATEST_SYNTAX_VERSION );
445 $this->messageGroupMetadata->set(
448 $pageSettings->shouldEnableTransclusion() ?
'1' :
'0'
451 $this->handlePriorityLanguages( $operation->getPage(), $pageSettings, $user );
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 )
464 return Status::newGood();
467 private function handlePriorityLanguages(
468 TranslatablePage $page,
469 TranslatablePageSettings $pageSettings,
472 $languages = $pageSettings->getPriorityLanguages() ?
473 implode(
',', $pageSettings->getPriorityLanguages() ) :
475 $force = $pageSettings->shouldForcePriorityLanguage() ?
'on' :
false;
476 $hasPriorityConfig = $languages || $force;
480 if ( $hasPriorityConfig && $pageSettings->getPriorityLanguageComment() !==
'' ) {
481 $reason = $pageSettings->getPriorityLanguageComment();
486 $groupId = $page->getMessageGroupId();
488 $opLanguages = $this->messageGroupMetadata->get( $groupId,
'prioritylangs' );
489 $opForce = $this->messageGroupMetadata->get( $groupId,
'priorityforce' );
490 $opReason = $this->messageGroupMetadata->get( $groupId,
'priorityreason' );
492 $this->messageGroupMetadata->set( $groupId,
'prioritylangs', $languages );
493 $this->messageGroupMetadata->set( $groupId,
'priorityforce', $force );
494 $this->messageGroupMetadata->set( $groupId,
'priorityreason', $reason );
497 $opLanguages !== $languages ||
500 ( $opForce !== $force && !( $force ===
false && $opForce ===
'off' ) ) ||
503 ( (
string)$opReason !== (
string)$reason )
505 $logComment = $reason ===
false ?
'' : $reason;
507 'languages' => $languages,
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 );
522 private function prepareTranslationUnits( TranslatablePage $page, ParserOutput $parserOutput ): array {
523 $highest = (int)$this->messageGroupMetadata->get( $page->getMessageGroupId(),
'maxid' );
525 $store = $this->translationUnitStoreFactory->getReader( $page->getPageIdentity() );
526 $storedUnits = $store->getUnits();
529 $displayTitle =
new TranslationUnit(
530 $this->titleFormatter->getPrefixedText( $page->getPageIdentity() ),
531 TranslatablePage::DISPLAY_TITLE_UNIT_ID
534 $units = [ TranslatablePage::DISPLAY_TITLE_UNIT_ID => $displayTitle ] + $parserOutput->units();
537 foreach ( array_keys( $storedUnits ) as $key ) {
538 $highest = max( $highest, (
int)$key );
540 foreach ( $units as $_ ) {
541 $highest = max( $highest, (
int)$_->id );
544 foreach ( $units as $s ) {
547 if ( $s->id === TranslationUnit::NEW_UNIT_ID ) {
549 $s->id = (string)( ++$highest );
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;
564 $deletedUnits = $storedUnits;
565 foreach ( $units as $s ) {
566 unset( $deletedUnits[$s->id] );
569 return [ $units, $deletedUnits ];
572 private function updateSectionMarkers(
573 TranslatablePage $page,
574 Authority $authority,
575 MessageLocalizer $localizer,
576 TranslatablePageMarkOperation $operation
578 $pageUpdater = $this->wikiPageFactory->newFromTitle( $page->getPageIdentity() )
579 ->newPageUpdater( $authority->getUser() );
580 $content = ContentHandler::makeContent(
581 $operation->getParserOutput()->sourcePageTextForSaving(),
584 $comment = CommentStoreComment::newUnsavedComment(
585 Message::newFromKey(
'tpt-mark-summary' )->inContentLanguage()->text()
588 $pageUpdater->setContent( SlotRecord::MAIN, $content );
589 if ( $authority->authorizeWrite(
'autopatrol', $page->getTitle() ) ) {
590 $pageUpdater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
592 $newRevisionRecord = $pageUpdater->saveRevision( $comment, EDIT_FORCE_BOT | EDIT_UPDATE );
594 $this->throwIfEditFailed( $pageUpdater, $localizer );
596 return $newRevisionRecord !==
null ? $newRevisionRecord->getId() :
null;
600 private function throwIfEditFailed( PageUpdater $pageUpdater, MessageLocalizer $messageLocalizer ): void {
601 $status = $pageUpdater->getStatus();
602 if ( !$status->isOK() ) {
603 throw new TranslatablePageMarkException(
606 $this->formatterFactory->getStatusFormatter( $messageLocalizer )->getMessage( $status ),
624 $changedMessages = array_filter( $sections, static function ( $section ) {
625 return $section->type !==
'old';
628 if ( !$changedMessages ) {
632 if ( $isFirstMark ) {
633 $subscribers = $this->messageGroupSubscription->getGroupSubscribers( $groupId );
634 if ( $subscribers instanceof EmptyIterator ) {
643 $code = $group->getSourceLanguage();
644 $pageTitle = $group->getTitle();
646 foreach ( $changedMessages as $message ) {
647 $messageTitle = Title::makeTitle( NS_TRANSLATIONS,
"$pageTitle/$message->id/$code" );
648 $this->messageGroupSubscription->queueMessage(
650 $message->type ===
'new'
651 ? MessageGroupSubscription::STATE_ADDED
652 : MessageGroupSubscription::STATE_UPDATED,
656 $this->messageGroupSubscription->queueNotificationJob();