diff options
Diffstat (limited to 'MLEB/Translate/src/PageTranslation/PageTranslationSpecialPage.php')
-rw-r--r-- | MLEB/Translate/src/PageTranslation/PageTranslationSpecialPage.php | 1271 |
1 files changed, 1271 insertions, 0 deletions
diff --git a/MLEB/Translate/src/PageTranslation/PageTranslationSpecialPage.php b/MLEB/Translate/src/PageTranslation/PageTranslationSpecialPage.php new file mode 100644 index 00000000..730f69ea --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/PageTranslationSpecialPage.php @@ -0,0 +1,1271 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use ContentHandler; +use DifferenceEngine; +use Html; +use JobQueueGroup; +use ManualLogEntry; +use MediaWiki\Cache\LinkBatchFactory; +use MediaWiki\Extension\Translate\Utilities\LanguagesMultiselectWidget; +use MediaWiki\Hook\BeforeParserFetchTemplateRevisionRecordHook; +use MediaWiki\Languages\LanguageFactory; +use MediaWiki\Languages\LanguageNameUtils; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\User\UserIdentity; +use MessageGroups; +use MessageGroupStatsRebuildJob; +use MessageIndex; +use MessageWebImporter; +use MWException; +use OOUI\ButtonInputWidget; +use OOUI\CheckboxInputWidget; +use OOUI\FieldLayout; +use OOUI\FieldsetLayout; +use OOUI\TextInputWidget; +use PermissionsError; +use RevTag; +use SpecialNotifyTranslators; +use SpecialPage; +use Title; +use TranslatablePage; +use TranslateMetadata; +use TranslateUtils; +use TranslationsUpdateJob; +use WebRequest; +use Wikimedia\Rdbms\IResultWrapper; +use WikiPage; +use Xml; +use function count; +use function wfEscapeWikiText; +use function wfGetDB; +use const EDIT_FORCE_BOT; +use const EDIT_UPDATE; + +/** + * A special page for marking revisions of pages for translation. + * + * This page is the main tool for translation administrators in the wiki. + * It will list all pages in their various states and provides actions + * that are suitable for given translatable page. + * + * @author Niklas Laxström + * @author Siebrand Mazeland + * @license GPL-2.0-or-later + */ +class PageTranslationSpecialPage extends SpecialPage { + private const LATEST_SYNTAX_VERSION = '2'; + private const DEFAULT_SYNTAX_VERSION = '1'; + /** @var LanguageNameUtils */ + private $languageNameUtils; + /** @var LanguageFactory */ + private $languageFactory; + /** @var TranslationUnitStoreFactory */ + private $translationUnitStoreFactory; + /** @var TranslatablePageParser */ + private $translatablePageParser; + /** @var LinkBatchFactory */ + private $linkBatchFactory; + + public function __construct( + LanguageNameUtils $languageNameUtils, + LanguageFactory $languageFactory, + TranslationUnitStoreFactory $translationUnitStoreFactory, + TranslatablePageParser $translatablePageParser, + LinkBatchFactory $linkBatchFactory + ) { + parent::__construct( 'PageTranslation' ); + $this->languageNameUtils = $languageNameUtils; + $this->languageFactory = $languageFactory; + $this->translationUnitStoreFactory = $translationUnitStoreFactory; + $this->translatablePageParser = $translatablePageParser; + $this->linkBatchFactory = $linkBatchFactory; + } + + public function doesWrites(): bool { + return true; + } + + protected function getGroupName(): string { + return 'translation'; + } + + public function execute( $parameters ) { + $this->setHeaders(); + + $user = $this->getUser(); + $request = $this->getRequest(); + + $target = $request->getText( 'target', $parameters ); + $revision = $request->getInt( 'revision', 0 ); + $action = $request->getVal( 'do' ); + $out = $this->getOutput(); + $out->addModules( 'ext.translate.special.pagetranslation' ); + $out->addHelpLink( 'Help:Extension:Translate/Page_translation_example' ); + $out->enableOOUI(); + + if ( $target === '' ) { + $this->listPages(); + + return; + } + + // Anything else than listing the pages need permissions + if ( !$user->isAllowed( 'pagetranslation' ) ) { + throw new PermissionsError( 'pagetranslation' ); + } + + $title = Title::newFromText( $target ); + if ( !$title ) { + $out->wrapWikiMsg( Html::errorBox( '$1' ), [ 'tpt-badtitle', $target ] ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } elseif ( !$title->exists() ) { + $out->wrapWikiMsg( + Html::errorBox( '$1' ), + [ 'tpt-nosuchpage', $title->getPrefixedText() ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } + + // Check token for all POST actions here + if ( $request->wasPosted() && !$user->matchEditToken( $request->getText( 'token' ) ) ) { + throw new PermissionsError( 'pagetranslation' ); + } + + if ( $action === 'mark' ) { + // Has separate form + $this->onActionMark( $title, $revision ); + + return; + } + + // On GET requests, show form which has token + if ( !$request->wasPosted() ) { + if ( $action === 'unlink' ) { + $this->showUnlinkConfirmation( $title ); + } else { + $params = [ + 'do' => $action, + 'target' => $title->getPrefixedText(), + 'revision' => $revision, + ]; + $this->showGenericConfirmation( $params ); + } + + return; + } + + if ( $action === 'discourage' || $action === 'encourage' ) { + $id = TranslatablePage::getMessageGroupIdFromTitle( $title ); + $current = MessageGroups::getPriority( $id ); + + if ( $action === 'encourage' ) { + $new = ''; + } else { + $new = 'discouraged'; + } + + if ( $new !== $current ) { + MessageGroups::setPriority( $id, $new ); + $entry = new ManualLogEntry( 'pagetranslation', $action ); + $entry->setPerformer( $user ); + $entry->setTarget( $title ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + + // Defer stats purging of parent aggregate groups. Shared groups can contain other + // groups as well, which we do not need to update. We could filter non-aggregate + // groups out, or use MessageGroups::getParentGroups, though it has an inconvenient + // return value format for this use case. + $group = MessageGroups::getGroup( $id ); + $sharedGroupIds = MessageGroups::getSharedGroups( $group ); + if ( $sharedGroupIds !== [] ) { + $job = MessageGroupStatsRebuildJob::newRefreshGroupsJob( $sharedGroupIds ); + JobQueueGroup::singleton()->push( $job ); + } + + // Show updated page with a notice + $this->listPages(); + + return; + } + + if ( $action === 'unlink' ) { + $page = TranslatablePage::newFromTitle( $title ); + + $content = ContentHandler::makeContent( + $page->getStrippedSourcePageText(), + $title + ); + + $status = TranslateUtils::doPageEdit( + WikiPage::factory( $title ), + $content, + $this->getUser(), + $this->msg( 'tpt-unlink-summary' )->inContentLanguage()->text(), + EDIT_FORCE_BOT | EDIT_UPDATE + ); + + if ( !$status->isOK() ) { + $out->wrapWikiMsg( + Html::errorBox( '$1' ), + [ 'tpt-edit-failed', $status->getWikiText() ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } + + $page = TranslatablePage::newFromTitle( $title ); + $this->unmarkPage( $page, $user ); + $out->wrapWikiMsg( + Html::successBox( '$1' ), + [ 'tpt-unmarked', $title->getPrefixedText() ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } + + if ( $action === 'unmark' ) { + $page = TranslatablePage::newFromTitle( $title ); + $this->unmarkPage( $page, $user ); + $out->wrapWikiMsg( + Html::successBox( '$1' ), + [ 'tpt-unmarked', $title->getPrefixedText() ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + } + } + + protected function onActionMark( Title $title, int $revision ): void { + $request = $this->getRequest(); + $out = $this->getOutput(); + + $out->addModuleStyles( 'ext.translate.specialpages.styles' ); + + if ( $revision === 0 ) { + // Get the latest revision + $revision = (int)$title->getLatestRevID(); + } + + // This also catches the case where revision does not belong to the title + if ( $revision !== (int)$title->getLatestRevID() ) { + // We do want to notify the reviewer if the underlying page changes during review + $target = $title->getFullURL( [ 'oldid' => $revision ] ); + $link = "<span class='plainlinks'>[$target $revision]</span>"; + $out->wrapWikiMsg( + Html::warningBox( '$1' ), + [ 'tpt-oldrevision', $title->getPrefixedText(), $link ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } + + // newFromRevision never fails, but getReadyTag might fail if revision does not belong + // to the page (checked above) + $page = TranslatablePage::newFromRevision( $title, $revision ); + if ( $page->getReadyTag() !== $title->getLatestRevID() ) { + $out->wrapWikiMsg( + Html::errorBox( '$1' ), + [ 'tpt-notsuitable', $title->getPrefixedText() ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } + + $firstMark = $page->getMarkedTag() === false; + + $parse = $this->translatablePageParser->parse( $page->getText() ); + [ $units, $deletedUnits ] = $this->prepareTranslationUnits( $page, $parse ); + + $error = $this->validateUnitIds( $units ); + + // Non-fatal error which prevents saving + if ( !$error && $request->wasPosted() ) { + // Check if user wants to translate title + // If not, remove it from the list of units + if ( !$request->getCheck( 'translatetitle' ) ) { + $units = array_filter( $units, static function ( $s ) { + return $s->id !== TranslatablePage::DISPLAY_TITLE_UNIT_ID; + } ); + } + + $setVersion = $firstMark || $request->getCheck( 'use-latest-syntax' ); + $transclusion = $request->getCheck( 'transclusion' ); + + $err = $this->markForTranslation( $page, $parse, $units, $setVersion, $transclusion ); + + if ( $err ) { + call_user_func_array( [ $out, 'addWikiMsg' ], $err ); + } else { + $this->showSuccess( $page, $firstMark, count( $units ) ); + } + + return; + } + + $this->showPage( $page, $parse, $units, $deletedUnits, $firstMark ); + } + + /** + * Displays success message and other instructions after a page has been marked for translation. + * @param TranslatablePage $page + * @param bool $firstMark true if it is the first time the page is being marked for translation. + * @param int $unitCount + * @return void + */ + private function showSuccess( + TranslatablePage $page, bool $firstMark, int $unitCount + ): void { + $titleText = $page->getTitle()->getPrefixedText(); + $num = $this->getLanguage()->formatNum( $unitCount ); + $link = SpecialPage::getTitleFor( 'Translate' )->getFullURL( [ + 'group' => $page->getMessageGroupId(), + 'action' => 'page', + 'filter' => '', + ] ); + + $this->getOutput()->wrapWikiMsg( + Html::successBox( '$1' ), + [ 'tpt-saveok', $titleText, $num, $link ] + ); + + // If the page is being marked for translation for the first time + // add a link to Special:PageMigration. + if ( $firstMark ) { + $this->getOutput()->addWikiMsg( 'tpt-saveok-first' ); + } + + // If TranslationNotifications is installed, and the user can notify + // translators, add a convenience link. + if ( method_exists( SpecialNotifyTranslators::class, 'execute' ) && + $this->getUser()->isAllowed( SpecialNotifyTranslators::$right ) + ) { + $link = SpecialPage::getTitleFor( 'NotifyTranslators' )->getFullURL( + [ 'tpage' => $page->getTitle()->getArticleID() ] + ); + $this->getOutput()->addWikiMsg( 'tpt-offer-notify', $link ); + } + + $this->getOutput()->addWikiMsg( 'tpt-list-pages-in-translations' ); + } + + protected function showGenericConfirmation( array $params ): void { + $formParams = [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getFullURL(), + ]; + + $params['title'] = $this->getPageTitle()->getPrefixedText(); + $params['token'] = $this->getUser()->getEditToken(); + + $hidden = ''; + foreach ( $params as $key => $value ) { + $hidden .= Html::hidden( $key, $value ); + } + + $this->getOutput()->addHTML( + Html::openElement( 'form', $formParams ) . + $hidden . + $this->msg( 'tpt-generic-confirm' )->parseAsBlock() . + Xml::submitButton( + $this->msg( 'tpt-generic-button' )->text(), + [ 'class' => 'mw-ui-button mw-ui-progressive' ] + ) . + Html::closeElement( 'form' ) + ); + } + + protected function showUnlinkConfirmation( Title $target ): void { + $formParams = [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getFullURL(), + ]; + + $this->getOutput()->addHTML( + Html::openElement( 'form', $formParams ) . + Html::hidden( 'do', 'unlink' ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Html::hidden( 'target', $target->getPrefixedText() ) . + Html::hidden( 'token', $this->getUser()->getEditToken() ) . + $this->msg( 'tpt-unlink-confirm', $target->getPrefixedText() )->parseAsBlock() . + Xml::submitButton( + $this->msg( 'tpt-unlink-button' )->text(), + [ 'class' => 'mw-ui-button mw-ui-destructive' ] + ) . + Html::closeElement( 'form' ) + ); + } + + protected function unmarkPage( TranslatablePage $page, UserIdentity $user ): void { + $page->unmarkTranslatablePage(); + $page->getTitle()->invalidateCache(); + + $entry = new ManualLogEntry( 'pagetranslation', 'unmark' ); + $entry->setPerformer( $user ); + $entry->setTarget( $page->getTitle() ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + + public function loadPagesFromDB(): IResultWrapper { + $dbr = TranslateUtils::getSafeReadDB(); + $tables = [ 'page', 'revtag' ]; + $vars = [ + 'page_id', + 'page_namespace', + 'page_title', + 'page_latest', + 'MAX(rt_revision) AS rt_revision', + 'rt_type' + ]; + $conds = [ + 'page_id=rt_page', + 'rt_type' => [ RevTag::getType( 'tp:mark' ), RevTag::getType( 'tp:tag' ) ], + ]; + $options = [ + 'ORDER BY' => 'page_namespace, page_title', + 'GROUP BY' => 'page_id, page_namespace, page_title, page_latest, rt_type', + ]; + + return $dbr->select( $tables, $vars, $conds, __METHOD__, $options ); + } + + protected function buildPageArray( IResultWrapper $res ): array { + $pages = []; + foreach ( $res as $r ) { + // We have multiple rows for same page, because of different tags + if ( !isset( $pages[$r->page_id] ) ) { + $pages[$r->page_id] = []; + $title = Title::newFromRow( $r ); + $pages[$r->page_id]['title'] = $title; + $pages[$r->page_id]['latest'] = (int)$title->getLatestRevID(); + } + + $tag = RevTag::typeToTag( $r->rt_type ); + $pages[$r->page_id][$tag] = (int)$r->rt_revision; + } + + return $pages; + } + + /** + * Classify a list of pages and amend them with additional metadata. + * + * @param array[] $pages + * @return array[] + * @phan-return array{proposed:array[],active:array[],broken:array[],outdated:array[]} + */ + private function classifyPages( array $pages ): array { + // Preload stuff for performance + $messageGroupIdsForPreload = []; + foreach ( $pages as $i => $page ) { + $id = TranslatablePage::getMessageGroupIdFromTitle( $page['title'] ); + $messageGroupIdsForPreload[] = $id; + $pages[$i]['groupid'] = $id; + } + // Performance optimization: load only data we need to classify the pages + $metadata = TranslateMetadata::loadBasicMetadataForTranslatablePages( + $messageGroupIdsForPreload, + [ 'transclusion', 'version' ] + ); + + $out = [ + // The ideal state for pages: marked and up to date + 'active' => [], + 'proposed' => [], + 'outdated' => [], + 'broken' => [], + ]; + + foreach ( $pages as $page ) { + $groupId = $page['groupid']; + $group = MessageGroups::getGroup( $groupId ); + $page['discouraged'] = MessageGroups::getPriority( $group ) === 'discouraged'; + $page['version'] = $metadata[$groupId]['version'] ?? self::DEFAULT_SYNTAX_VERSION; + $page['transclusion'] = $metadata[$groupId]['transclusion'] ?? false; + + if ( !isset( $page['tp:mark'] ) ) { + // Never marked, check that the latest version is ready + if ( $page['tp:tag'] === $page['latest'] ) { + $out['proposed'][] = $page; + } // Otherwise, ignore such pages + } elseif ( $page['tp:tag'] === $page['latest'] ) { + if ( $page['tp:mark'] === $page['tp:tag'] ) { + // Marked and latest version is fine + $out['active'][] = $page; + } else { + $out['outdated'][] = $page; + } + } else { + // Marked but latest version is not fine + $out['broken'][] = $page; + } + } + + return $out; + } + + public function listPages(): void { + $out = $this->getOutput(); + + $res = $this->loadPagesFromDB(); + $allPages = $this->buildPageArray( $res ); + if ( !count( $allPages ) ) { + $out->addWikiMsg( 'tpt-list-nopages' ); + + return; + } + + $lb = $this->linkBatchFactory->newLinkBatch(); + $lb->setCaller( __METHOD__ ); + foreach ( $allPages as $page ) { + $lb->addObj( $page['title'] ); + } + $lb->execute(); + + $types = $this->classifyPages( $allPages ); + + $pages = $types['proposed']; + if ( $pages ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-new-pages-title' ); + $out->addWikiMsg( 'tpt-new-pages', count( $pages ) ); + $out->addHTML( $this->getPageList( $pages, 'proposed' ) ); + } + + $pages = $types['broken']; + if ( $pages ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-other-pages-title' ); + $out->addWikiMsg( 'tpt-other-pages', count( $pages ) ); + $out->addHTML( $this->getPageList( $pages, 'broken' ) ); + } + + $pages = $types['outdated']; + if ( $pages ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-outdated-pages-title' ); + $out->addWikiMsg( 'tpt-outdated-pages', count( $pages ) ); + $out->addHTML( $this->getPageList( $pages, 'outdated' ) ); + } + + $pages = $types['active']; + if ( $pages ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-old-pages-title' ); + $out->addWikiMsg( 'tpt-old-pages', count( $pages ) ); + $out->addHTML( $this->getPageList( $pages, 'active' ) ); + } + } + + private function actionLinks( array $page, string $type ): string { + // Performance optimization to avoid calling $this->msg in a loop + static $messageCache = null; + if ( $messageCache === null ) { + $messageCache = [ + 'mark' => $this->msg( 'tpt-rev-mark' )->text(), + 'mark-tooltip' => $this->msg( 'tpt-rev-mark-tooltip' )->text(), + 'encourage' => $this->msg( 'tpt-rev-encourage' )->text(), + 'encourage-tooltip' => $this->msg( 'tpt-rev-encourage-tooltip' )->text(), + 'discourage' => $this->msg( 'tpt-rev-discourage' )->text(), + 'discourage-tooltip' => $this->msg( 'tpt-rev-discourage-tooltip' )->text(), + 'unmark' => $this->msg( 'tpt-rev-unmark' )->text(), + 'unmark-tooltip' => $this->msg( 'tpt-rev-unmark-tooltip' )->text(), + 'pipe-separator' => $this->msg( 'pipe-separator' )->escaped(), + ]; + } + + $actions = []; + /** @var Title $title */ + $title = $page['title']; + $user = $this->getUser(); + + // Class to allow one-click POSTs + $js = [ 'class' => 'mw-translate-jspost' ]; + + if ( $user->isAllowed( 'pagetranslation' ) ) { + // Enable re-marking of all pages to allow changing of priority languages + // or migration to the new syntax version + if ( $type !== 'broken' ) { + $actions[] = $this->getLinkRenderer()->makeKnownLink( + $this->getPageTitle(), + $messageCache['mark'], + [ 'title' => $messageCache['mark-tooltip'] ], + [ + 'do' => 'mark', + 'target' => $title->getPrefixedText(), + 'revision' => $title->getLatestRevID(), + ] + ); + } + + if ( $type !== 'proposed' ) { + if ( $page['discouraged'] ) { + $actions[] = $this->getLinkRenderer()->makeKnownLink( + $this->getPageTitle(), + $messageCache['encourage'], + [ 'title' => $messageCache['encourage-tooltip'] ] + $js, + [ + 'do' => 'encourage', + 'target' => $title->getPrefixedText(), + 'revision' => -1, + ] + ); + } else { + $actions[] = $this->getLinkRenderer()->makeKnownLink( + $this->getPageTitle(), + $messageCache['discourage'], + [ 'title' => $messageCache['discourage-tooltip'] ] + $js, + [ + 'do' => 'discourage', + 'target' => $title->getPrefixedText(), + 'revision' => -1, + ] + ); + } + + $actions[] = $this->getLinkRenderer()->makeKnownLink( + $this->getPageTitle(), + $messageCache['unmark'], + [ 'title' => $messageCache['unmark-tooltip'] ], + [ + 'do' => $type === 'broken' ? 'unmark' : 'unlink', + 'target' => $title->getPrefixedText(), + 'revision' => -1, + ] + ); + } + } + + if ( !$actions ) { + return ''; + } + + return '<div>' . implode( $messageCache['pipe-separator'], $actions ) . '</div>'; + } + + public function validateUnitIds( array $units ): bool { + $usedNames = []; + $error = false; + + $ic = preg_quote( TranslationUnit::UNIT_MARKER_INVALID_CHARS, '~' ); + foreach ( $units as $s ) { + if ( preg_match( "~[$ic]~", $s->id ) ) { + $this->getOutput()->addElement( + 'p', + [ 'class' => 'errorbox' ], + $this->msg( 'tpt-invalid' )->params( $s->id )->text() + ); + $error = true; + } + + // We need to do checks for both new and existing units. + // Someone might have tampered with the page source adding + // duplicate or invalid markers. + $usedNames[$s->id] = ( $usedNames[$s->id] ?? 0 ) + 1; + } + foreach ( $usedNames as $name => $count ) { + if ( $count > 1 ) { + // Only show error once per duplicated translation unit + $this->getOutput()->addElement( + 'p', + [ 'class' => 'errorbox' ], + $this->msg( 'tpt-duplicate' )->params( $name )->text() + ); + $error = true; + } + } + + return $error; + } + + /** @return TranslationUnit[][] */ + private function prepareTranslationUnits( TranslatablePage $page, ParserOutput $parse ): array { + $highest = (int)TranslateMetadata::get( $page->getMessageGroupId(), 'maxid' ); + + $store = $this->translationUnitStoreFactory->getReader( $page->getTitle() ); + $storedUnits = $store->getUnits(); + $parsedUnits = $parse->units(); + + // Prepend the display title unit, which is not part of the page contents + $displayTitle = new TranslationUnit( + $page->getTitle()->getPrefixedText(), + TranslatablePage::DISPLAY_TITLE_UNIT_ID + ); + $parsedUnits = [ TranslatablePage::DISPLAY_TITLE_UNIT_ID => $displayTitle ] + $parsedUnits; + + // Figure out the largest used translation unit id + foreach ( array_keys( $storedUnits ) as $key ) { + $highest = max( $highest, (int)$key ); + } + foreach ( $parsedUnits as $_ ) { + $highest = max( $highest, (int)$_->id ); + } + + foreach ( $parsedUnits as $s ) { + $s->type = 'old'; + + if ( $s->id === TranslationUnit::NEW_UNIT_ID ) { + $s->type = 'new'; + $s->id = (string)( ++$highest ); + } else { + if ( isset( $storedUnits[$s->id] ) ) { + $storedText = $storedUnits[$s->id]->text; + if ( $s->text !== $storedText ) { + $s->type = 'changed'; + $s->oldText = $storedText; + } + } + } + } + + // Figure out which units were deleted by removing the still existing units + $deletedUnits = $storedUnits; + foreach ( $parsedUnits as $s ) { + unset( $deletedUnits[$s->id] ); + } + + return [ $parsedUnits, $deletedUnits ]; + } + + private function showPage( + TranslatablePage $page, + ParserOutput $parse, + array $sections, + array $deletedUnits, + bool $firstMark + ): void { + $out = $this->getOutput(); + $out->setSubtitle( $this->getLinkRenderer()->makeKnownLink( $page->getTitle() ) ); + $out->addWikiMsg( 'tpt-showpage-intro' ); + + $formParams = [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getFullURL(), + 'class' => 'mw-tpt-sp-markform', + ]; + + $out->addHTML( + Xml::openElement( 'form', $formParams ) . + Html::hidden( 'do', 'mark' ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Html::hidden( 'revision', $page->getRevision() ) . + Html::hidden( 'target', $page->getTitle()->getPrefixedText() ) . + Html::hidden( 'token', $this->getUser()->getEditToken() ) + ); + + $out->wrapWikiMsg( '==$1==', 'tpt-sections-oldnew' ); + + $diffOld = $this->msg( 'tpt-diff-old' )->escaped(); + $diffNew = $this->msg( 'tpt-diff-new' )->escaped(); + $hasChanges = false; + + // Check whether page title was previously marked for translation. + // If the page is marked for translation the first time, default to checked. + $defaultChecked = $firstMark || $page->hasPageDisplayTitle(); + + $sourceLanguage = $this->languageFactory->getLanguage( $page->getSourceLanguageCode() ); + + foreach ( $sections as $s ) { + if ( $s->id === TranslatablePage::DISPLAY_TITLE_UNIT_ID ) { + // Set section type as new if title previously unchecked + $s->type = $defaultChecked ? $s->type : 'new'; + + // Checkbox for page title optional translation + $checkBox = new FieldLayout( + new CheckboxInputWidget( [ + 'name' => 'translatetitle', + 'selected' => $defaultChecked, + ] ), + [ + 'label' => $this->msg( 'tpt-translate-title' )->text(), + 'align' => 'inline', + 'classes' => [ 'mw-tpt-m-vertical' ] + ] + ); + $out->addHTML( $checkBox->toString() ); + } + + if ( $s->type === 'new' ) { + $hasChanges = true; + $name = $this->msg( 'tpt-section-new', $s->id )->escaped(); + } else { + $name = $this->msg( 'tpt-section', $s->id )->escaped(); + } + + if ( $s->type === 'changed' ) { + $hasChanges = true; + $diff = new DifferenceEngine(); + $diff->setTextLanguage( $sourceLanguage ); + $diff->setReducedLineNumbers(); + + $oldContent = ContentHandler::makeContent( $s->getOldText(), $diff->getTitle() ); + $newContent = ContentHandler::makeContent( $s->getText(), $diff->getTitle() ); + + $diff->setContent( $oldContent, $newContent ); + + $text = $diff->getDiff( $diffOld, $diffNew ); + $diffOld = $diffNew = null; + $diff->showDiffStyle(); + + $id = "tpt-sect-{$s->id}-action-nofuzzy"; + $checkLabel = new FieldLayout( + new CheckboxInputWidget( [ + 'name' => $id, + 'selected' => false, + ] ), + [ + 'label' => $this->msg( 'tpt-action-nofuzzy' )->text(), + 'align' => 'inline', + 'classes' => [ 'mw-tpt-m-vertical' ] + ] + ); + $text = $checkLabel->toString() . $text; + } else { + $text = TranslateUtils::convertWhiteSpaceToHTML( $s->getText() ); + } + + # For changed text, the language is set by $diff->setTextLanguage() + $lang = $s->type === 'changed' ? null : $sourceLanguage; + $out->addHTML( MessageWebImporter::makeSectionElement( + $name, + $s->type, + $text, + $lang + ) ); + + foreach ( $s->getIssues() as $issue ) { + $severity = $issue->getSeverity(); + if ( $severity === TranslationUnitIssue::WARNING ) { + $box = Html::warningBox( $this->msg( $issue )->escaped() ); + } elseif ( $severity === TranslationUnitIssue::ERROR ) { + $box = Html::errorBox( $this->msg( $issue )->escaped() ); + } else { + throw new MWException( + "Unknown severity: $severity for key: {$issue->getKey()}" + ); + } + + $out->addHTML( $box ); + } + } + + if ( $deletedUnits ) { + $hasChanges = true; + $out->wrapWikiMsg( '==$1==', 'tpt-sections-deleted' ); + + foreach ( $deletedUnits as $s ) { + $name = $this->msg( 'tpt-section-deleted', $s->id )->escaped(); + $text = TranslateUtils::convertWhiteSpaceToHTML( $s->getText() ); + $out->addHTML( MessageWebImporter::makeSectionElement( + $name, + 'deleted', + $text, + $sourceLanguage + ) ); + } + } + + // Display template changes if applicable + if ( $page->getMarkedTag() !== false ) { + $hasChanges = true; + $newTemplate = $parse->sourcePageTemplateForDiffs(); + $oldPage = TranslatablePage::newFromRevision( + $page->getTitle(), + $page->getMarkedTag() + ); + $oldTemplate = $this->translatablePageParser + ->parse( $oldPage->getText() ) + ->sourcePageTemplateForDiffs(); + + if ( $oldTemplate !== $newTemplate ) { + $out->wrapWikiMsg( '==$1==', 'tpt-sections-template' ); + + $diff = new DifferenceEngine(); + $diff->setTextLanguage( $sourceLanguage ); + + $oldContent = ContentHandler::makeContent( $oldTemplate, $diff->getTitle() ); + $newContent = ContentHandler::makeContent( $newTemplate, $diff->getTitle() ); + + $diff->setContent( $oldContent, $newContent ); + + $text = $diff->getDiff( + $this->msg( 'tpt-diff-old' )->escaped(), + $this->msg( 'tpt-diff-new' )->escaped() + ); + $diff->showDiffStyle(); + $diff->setReducedLineNumbers(); + + $out->addHTML( Xml::tags( 'div', [], $text ) ); + } + } + + if ( !$hasChanges ) { + $out->wrapWikiMsg( Html::successBox( '$1' ), 'tpt-mark-nochanges' ); + } + + $this->priorityLanguagesForm( $page ); + + // If an existing page does not have the supportsTransclusion flag, keep the checkbox unchecked, + // If the page is being marked for translation for the first time, the checkbox can be checked + $this->templateTransclusionForm( $page->supportsTransclusion() ?? $firstMark ); + + $version = TranslateMetadata::getWithDefaultValue( + $page->getMessageGroupId(), 'version', self::DEFAULT_SYNTAX_VERSION + ); + $this->syntaxVersionForm( $version, $firstMark ); + + $submitButton = new FieldLayout( + new ButtonInputWidget( [ + 'label' => $this->msg( 'tpt-submit' )->text(), + 'type' => 'submit', + 'flags' => [ 'primary', 'progressive' ], + ] ), + [ + 'label' => null, + 'align' => 'top', + ] + ); + + $out->addHTML( $submitButton->toString() ); + $out->addHTML( '</form>' ); + } + + private function priorityLanguagesForm( TranslatablePage $page ): void { + $groupId = $page->getMessageGroupId(); + $interfaceLanguage = $this->getLanguage()->getCode(); + $storedLanguages = (string)TranslateMetadata::get( $groupId, 'prioritylangs' ); + $default = $storedLanguages !== '' ? explode( ',', $storedLanguages ) : []; + + $form = new FieldsetLayout( [ + 'items' => [ + new FieldLayout( + new LanguagesMultiselectWidget( [ + 'infusable' => true, + 'name' => 'prioritylangs', + 'id' => 'mw-translate-SpecialPageTranslation-prioritylangs', + 'languages' => TranslateUtils::getLanguageNames( $interfaceLanguage ), + 'default' => $default, + ] ), + [ + 'label' => $this->msg( 'tpt-select-prioritylangs' )->text(), + 'align' => 'top', + ] + ), + new FieldLayout( + new CheckboxInputWidget( [ + 'name' => 'forcelimit', + 'selected' => TranslateMetadata::get( $groupId, 'priorityforce' ) === 'on', + ] ), + [ + 'label' => $this->msg( 'tpt-select-prioritylangs-force' )->text(), + 'align' => 'inline', + ] + ), + new FieldLayout( + new TextInputWidget( [ + 'name' => 'priorityreason', + ] ), + [ + 'label' => $this->msg( 'tpt-select-prioritylangs-reason' )->text(), + 'align' => 'top', + ] + ), + + ], + ] ); + + $this->getOutput()->wrapWikiMsg( '==$1==', 'tpt-sections-prioritylangs' ); + $this->getOutput()->addHTML( $form->toString() ); + } + + private function syntaxVersionForm( string $version, bool $firstMark ): void { + $out = $this->getOutput(); + + if ( $version === self::LATEST_SYNTAX_VERSION || $firstMark ) { + return; + } + + $out->wrapWikiMsg( '==$1==', 'tpt-sections-syntaxversion' ); + $out->addWikiMsg( + 'tpt-syntaxversion-text', + '<code>' . wfEscapeWikiText( '<span lang="en" dir="ltr">...</span>' ) . '</code>', + '<code>' . wfEscapeWikiText( '<translate nowrap>...</translate>' ) . '</code>' + ); + + $checkBox = new FieldLayout( + new CheckboxInputWidget( [ + 'name' => 'use-latest-syntax' + ] ), + [ + 'label' => $out->msg( 'tpt-syntaxversion-label' )->text(), + 'align' => 'inline', + ] + ); + + $out->addHTML( $checkBox->toString() ); + } + + private function templateTransclusionForm( bool $supportsTransclusion ): void { + // Transclusion is only supported if this hook is available so avoid showing the + // form if it's not. This hook should be available for MW >= 1.36 + if ( !interface_exists( BeforeParserFetchTemplateRevisionRecordHook::class ) ) { + return; + } + + $out = $this->getOutput(); + $out->wrapWikiMsg( '==$1==', 'tpt-transclusion' ); + + $checkBox = new FieldLayout( + new CheckboxInputWidget( [ + 'name' => 'transclusion', + 'selected' => $supportsTransclusion + ] ), + [ + 'label' => $out->msg( 'tpt-transclusion-label' )->text(), + 'align' => 'inline', + ] + ); + + $out->addHTML( $checkBox->toString() ); + } + + /** + * This function does the heavy duty of marking a page. + * - Updates the source page with section markers. + * - Updates translate_sections table + * - Updates revtags table + * - Sets up renderjobs to update the translation pages + * - Invalidates caches + * - Adds interim cache for MessageIndex + * + * @param TranslatablePage $page + * @param ParserOutput $parse + * @param TranslationUnit[] $sections + * @param bool $updateVersion + * @param bool $transclusion + * @return array|bool + */ + protected function markForTranslation( + TranslatablePage $page, + ParserOutput $parse, + array $sections, + bool $updateVersion, + bool $transclusion + ) { + // Add the section markers to the source page + $wikiPage = WikiPage::factory( $page->getTitle() ); + $content = ContentHandler::makeContent( + $parse->sourcePageTextForSaving(), + $page->getTitle() + ); + + $status = TranslateUtils::doPageEdit( + $wikiPage, + $content, + $this->getUser(), + $this->msg( 'tpt-mark-summary' )->inContentLanguage()->text(), + EDIT_FORCE_BOT | EDIT_UPDATE + ); + + if ( !$status->isOK() ) { + return [ 'tpt-edit-failed', $status->getWikiText() ]; + } + + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable + $newRevisionRecord = $status->value['revision-record']; + // In theory it is either null or RevisionRecord object, + // not a RevisionRecord object with null id, but who knows + if ( $newRevisionRecord instanceof RevisionRecord ) { + $newRevisionId = $newRevisionRecord->getId(); + } else { + $newRevisionId = null; + } + + // Probably a no-change edit, so no new revision was assigned. + // Get the latest revision manually + // Could also occur on the off chance $newRevisionRecord->getId() returns null + if ( $newRevisionId === null ) { + $newRevisionId = $page->getTitle()->getLatestRevID(); + } + + $inserts = []; + $changed = []; + $groupId = $page->getMessageGroupId(); + $maxid = (int)TranslateMetadata::get( $groupId, 'maxid' ); + + $pageId = $page->getTitle()->getArticleID(); + /** @var TranslationUnit $s */ + foreach ( array_values( $sections ) as $index => $s ) { + $maxid = max( $maxid, (int)$s->id ); + $changed[] = $s->id; + + if ( $this->getRequest()->getCheck( "tpt-sect-{$s->id}-action-nofuzzy" ) ) { + // TranslationsUpdateJob will only fuzzy when type is changed + $s->type = 'old'; + } + + $inserts[] = [ + 'trs_page' => $pageId, + 'trs_key' => $s->id, + 'trs_text' => $s->getText(), + 'trs_order' => $index + ]; + } + + $dbw = wfGetDB( DB_PRIMARY ); + $dbw->delete( + 'translate_sections', + [ 'trs_page' => $page->getTitle()->getArticleID() ], + __METHOD__ + ); + $dbw->insert( 'translate_sections', $inserts, __METHOD__ ); + TranslateMetadata::set( $groupId, 'maxid', $maxid ); + if ( $updateVersion ) { + TranslateMetadata::set( $groupId, 'version', self::LATEST_SYNTAX_VERSION ); + } + + $page->setTransclusion( $transclusion ); + + $page->addMarkedTag( $newRevisionId ); + MessageGroups::singleton()->recache(); + + // Store interim cache + $group = $page->getMessageGroup(); + $newKeys = $group->makeGroupKeys( $changed ); + MessageIndex::singleton()->storeInterim( $group, $newKeys ); + + $job = TranslationsUpdateJob::newFromPage( $page, $sections ); + JobQueueGroup::singleton()->push( $job ); + + $this->handlePriorityLanguages( $this->getRequest(), $page ); + + // Logging + $entry = new ManualLogEntry( 'pagetranslation', 'mark' ); + $entry->setPerformer( $this->getUser() ); + $entry->setTarget( $page->getTitle() ); + $entry->setParameters( [ + 'revision' => $newRevisionId, + 'changed' => count( $changed ), + ] ); + $logid = $entry->insert(); + $entry->publish( $logid ); + + // Clear more caches + $page->getTitle()->invalidateCache(); + + return false; + } + + /** + * @param WebRequest $request + * @param TranslatablePage $page + * @return void + */ + protected function handlePriorityLanguages( WebRequest $request, TranslatablePage $page ): void { + // Get the priority languages from the request + // We've to do some extra work here because if JS is disabled, we will be getting + // the values split by newline. + $npLangs = rtrim( trim( $request->getVal( 'prioritylangs', '' ) ), ',' ); + $npLangs = implode( ',', explode( "\n", $npLangs ) ); + $npLangs = array_map( 'trim', explode( ',', $npLangs ) ); + $npLangs = array_unique( $npLangs ); + + $npForce = $request->getCheck( 'forcelimit' ) ? 'on' : 'off'; + $npReason = trim( $request->getText( 'priorityreason' ) ); + + // Remove invalid language codes. + $languages = $this->languageNameUtils->getLanguageNames(); + foreach ( $npLangs as $index => $language ) { + if ( !array_key_exists( $language, $languages ) ) { + unset( $npLangs[$index] ); + } + } + $npLangs = implode( ',', $npLangs ); + if ( $npLangs === '' ) { + $npLangs = false; + $npForce = false; + $npReason = false; + } + + $groupId = $page->getMessageGroupId(); + // old priority languages + $opLangs = TranslateMetadata::get( $groupId, 'prioritylangs' ); + $opForce = TranslateMetadata::get( $groupId, 'priorityforce' ); + $opReason = TranslateMetadata::get( $groupId, 'priorityreason' ); + + TranslateMetadata::set( $groupId, 'prioritylangs', $npLangs ); + TranslateMetadata::set( $groupId, 'priorityforce', $npForce ); + TranslateMetadata::set( $groupId, 'priorityreason', $npReason ); + + if ( $opLangs !== $npLangs || $opForce !== $npForce || $opReason !== $npReason ) { + $params = [ + 'languages' => $npLangs, + 'force' => $npForce, + 'reason' => $npReason, + ]; + + $entry = new ManualLogEntry( 'pagetranslation', 'prioritylanguages' ); + $entry->setPerformer( $this->getUser() ); + $entry->setTarget( $page->getTitle() ); + $entry->setParameters( $params ); + $entry->setComment( $npReason ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + } + + private function getPageList( array $pages, string $type ): string { + $items = []; + $tagsTextCache = []; + + $tagDiscouraged = $this->msg( 'tpt-tag-discouraged' )->escaped(); + $tagOldSyntax = $this->msg( 'tpt-tag-oldsyntax' )->escaped(); + $tagNoTransclusionSupport = $this->msg( 'tpt-tag-no-transclusion-support' )->escaped(); + + foreach ( $pages as $page ) { + $link = $this->getLinkRenderer()->makeKnownLink( $page['title'] ); + $acts = $this->actionLinks( $page, $type ); + $tags = []; + if ( $page['discouraged'] ) { + $tags[] = $tagDiscouraged; + } + if ( $type !== 'proposed' ) { + if ( $page['version'] !== self::LATEST_SYNTAX_VERSION ) { + $tags[] = $tagOldSyntax; + } + + if ( $page['transclusion'] !== '1' ) { + $tags[] = $tagNoTransclusionSupport; + } + } + + $tagList = ''; + if ( $tags ) { + // Performance optimization to avoid calling $this->msg in a loop + $tagsKey = implode( '', $tags ); + $tagsTextCache[$tagsKey] = $tagsTextCache[$tagsKey] ?? + $this->msg( 'parentheses' ) + ->rawParams( $this->getLanguage()->pipeList( $tags ) ) + ->escaped(); + + $tagList = Html::rawElement( + 'span', + [ 'class' => 'mw-tpt-actions' ], + $tagsTextCache[$tagsKey] + ); + } + + $items[] = "<li>$link $tagList $acts</li>"; + } + + return '<ol>' . implode( "", $items ) . '</ol>'; + } +} |