src/PageTranslation/PageTranslationSpecialPage.php
<?php
declare( strict_types = 1 );
namespace MediaWiki\Extension\Translate\PageTranslation;
use ContentHandler;
use DifferenceEngine;
use ErrorPageError;
use IDBAccessObject;
use InvalidArgumentException;
use JobQueueGroup;
use ManualLogEntry;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
use MediaWiki\Extension\Translate\MessageGroupProcessing\RevTagStore;
use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundleState;
use MediaWiki\Extension\Translate\MessageProcessing\MessageGroupMetadata;
use MediaWiki\Extension\Translate\Statistics\RebuildMessageGroupStatsJob;
use MediaWiki\Extension\Translate\Synchronization\MessageWebImporter;
use MediaWiki\Extension\Translate\Utilities\LanguagesMultiselectWidget;
use MediaWiki\Extension\Translate\Utilities\Utilities;
use MediaWiki\Extension\TranslationNotifications\SpecialNotifyTranslators;
use MediaWiki\Html\Html;
use MediaWiki\Languages\LanguageFactory;
use MediaWiki\Page\PageRecord;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Request\WebRequest;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use OOUI\ButtonInputWidget;
use OOUI\CheckboxInputWidget;
use OOUI\DropdownInputWidget;
use OOUI\FieldLayout;
use OOUI\FieldsetLayout;
use OOUI\HtmlSnippet;
use OOUI\RadioInputWidget;
use OOUI\TextInputWidget;
use PermissionsError;
use UnexpectedValueException;
use UserBlockedError;
use Wikimedia\Rdbms\IResultWrapper;
use Xml;
use function count;
use function wfEscapeWikiText;
/**
* 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 DISPLAY_STATUS_MAPPING = [
TranslatablePageStatus::PROPOSED => 'proposed',
TranslatablePageStatus::ACTIVE => 'active',
TranslatablePageStatus::OUTDATED => 'outdated',
TranslatablePageStatus::BROKEN => 'broken'
];
private LanguageFactory $languageFactory;
private LinkBatchFactory $linkBatchFactory;
private JobQueueGroup $jobQueueGroup;
private PermissionManager $permissionManager;
private TranslatablePageMarker $translatablePageMarker;
private TranslatablePageParser $translatablePageParser;
private MessageGroupMetadata $messageGroupMetadata;
private TranslatablePageView $translatablePageView;
private TranslatablePageStateStore $translatablePageStateStore;
public function __construct(
LanguageFactory $languageFactory,
LinkBatchFactory $linkBatchFactory,
JobQueueGroup $jobQueueGroup,
PermissionManager $permissionManager,
TranslatablePageMarker $translatablePageMarker,
TranslatablePageParser $translatablePageParser,
MessageGroupMetadata $messageGroupMetadata,
TranslatablePageView $translatablePageView,
TranslatablePageStateStore $translatablePageStateStore
) {
parent::__construct( 'PageTranslation' );
$this->languageFactory = $languageFactory;
$this->linkBatchFactory = $linkBatchFactory;
$this->jobQueueGroup = $jobQueueGroup;
$this->permissionManager = $permissionManager;
$this->translatablePageMarker = $translatablePageMarker;
$this->translatablePageParser = $translatablePageParser;
$this->messageGroupMetadata = $messageGroupMetadata;
$this->translatablePageView = $translatablePageView;
$this->translatablePageStateStore = $translatablePageStateStore;
}
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->getIntOrNull( 'revision' );
$action = $request->getVal( 'do' );
$out = $this->getOutput();
$out->addModules( 'ext.translate.special.pagetranslation' );
$out->addModuleStyles( 'ext.translate.specialpages.styles' );
$out->addHelpLink( 'Help:Extension:Translate/Page_translation_example' );
$out->enableOOUI();
if ( $target === '' ) {
$this->listPages();
return;
}
$title = Title::newFromText( $target );
if ( !$title ) {
$out->wrapWikiMsg( Html::errorBox( '$1' ), [ 'tpt-badtitle', $target ] );
$out->addWikiMsg( 'tpt-list-pages-in-translations' );
return;
}
$this->getSkin()->setRelevantTitle( $title );
if ( !$title->exists() ) {
$out->wrapWikiMsg(
Html::errorBox( '$1' ),
[ 'tpt-nosuchpage', $title->getPrefixedText() ]
);
$out->addWikiMsg( 'tpt-list-pages-in-translations' );
return;
}
if ( $action === 'settings' && !$this->translatablePageView->isTranslationBannerNamespaceConfigured() ) {
$this->showTranslationStateRestricted();
return;
}
$block = $this->getBlock( $request, $user, $title );
if ( $action === 'settings' && !$request->wasPosted() ) {
$this->showTranslationSettings( $title, $block );
return;
}
if ( $block ) {
throw $block;
}
// Check token for all POST actions here
$csrfTokenSet = $this->getContext()->getCsrfTokenSet();
if ( $request->wasPosted() && !$csrfTokenSet->matchTokenField( 'token' ) ) {
throw new PermissionsError( 'pagetranslation' );
}
if ( $action === 'settings' && $request->wasPosted() ) {
$this->handleTranslationState( $title, $request->getRawVal( 'translatable-page-state' ) );
return;
}
// Anything other than listing the pages or manipulating settings needs permissions
if ( !$user->isAllowed( 'pagetranslation' ) ) {
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 = RebuildMessageGroupStatsJob::newRefreshGroupsJob( $sharedGroupIds );
$this->jobQueueGroup->push( $job );
}
// Show updated page with a notice
$this->listPages();
return;
}
if ( $action === 'unlink' || $action === 'unmark' ) {
try {
$this->translatablePageMarker->unmarkPage(
TranslatablePage::newFromTitle( $title ),
$user,
$action === 'unlink'
);
$out->wrapWikiMsg(
Html::successBox( '$1' ),
[ 'tpt-unmarked', $title->getPrefixedText() ]
);
} catch ( TranslatablePageMarkException $e ) {
$out->wrapWikiMsg(
Html::errorBox( '$1' ),
$e->getMessageObject()
);
}
$out->addWikiMsg( 'tpt-list-pages-in-translations' );
}
}
protected function onActionMark( Title $title, ?int $revision ): void {
$request = $this->getRequest();
$out = $this->getOutput();
$translateTitle = $request->getCheck( 'translatetitle' );
try {
$operation = $this->translatablePageMarker->getMarkOperation(
$title->toPageRecord(
$request->wasPosted() ? IDBAccessObject::READ_LATEST : IDBAccessObject::READ_NORMAL
),
$revision,
// If the request was not posted, validate all the units so that initially we display all the errors
// and then the user can choose whether they want to translate the title
!$request->wasPosted() || $translateTitle
);
} catch ( TranslatablePageMarkException $e ) {
$out->addHTML( Html::errorBox( $this->msg( $e->getMessageObject() )->parse() ) );
$out->addWikiMsg( 'tpt-list-pages-in-translations' );
return;
}
$unitNameValidationResult = $operation->getUnitValidationStatus();
// Non-fatal error which prevents saving
if ( $unitNameValidationResult->isOK() && $request->wasPosted() ) {
// Fetch priority language related information
[ $priorityLanguages, $forcePriorityLanguage, $priorityLanguageReason ] =
$this->getPriorityLanguage( $this->getRequest() );
$unitFuzzySelector = $request->getRawVal( 'unit-fuzzy-selector' );
if ( $unitFuzzySelector === 'all' ) {
$noFuzzyUnits = [];
} else {
// Get IDs of all changed units
$allChangedUnits = array_map(
static fn ( $unit ) => $unit->id,
array_filter(
$operation->getUnits(),
static fn ( $unit ) => $unit->type === 'changed'
)
);
if ( $unitFuzzySelector === 'none' ) {
$noFuzzyUnits = $allChangedUnits;
} else { // custom
$fuzzyUnits = $request->getArray( 'tpt-sect-fuzzy' ) ?? [];
// Filter the units that should not be fuzzied
$noFuzzyUnits = array_filter(
$allChangedUnits,
static fn ( $value ) => !in_array( $value, $fuzzyUnits )
);
}
}
$translatablePageSettings = new TranslatablePageSettings(
$priorityLanguages,
$forcePriorityLanguage,
$priorityLanguageReason,
$noFuzzyUnits,
$translateTitle,
$request->getCheck( 'use-latest-syntax' ),
$request->getCheck( 'transclusion' )
);
try {
$unitCount = $this->translatablePageMarker->markForTranslation(
$operation,
$translatablePageSettings,
$this->getUser()
);
$this->showSuccess( $operation->getPage(), $operation->isFirstMark(), $unitCount );
} catch ( TranslatablePageMarkException $e ) {
$out->wrapWikiMsg(
Html::errorBox( '$1' ),
$e->getMessageObject()
);
}
} else {
if ( !$unitNameValidationResult->isOK() ) {
$out->addHTML(
Html::errorBox(
$unitNameValidationResult->getHTML( false, false, $this->getLanguage() )
)
);
}
$this->showPage( $operation );
}
}
/**
* 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' );
}
private function showGenericConfirmation( array $params ): void {
$formParams = [
'method' => 'post',
'action' => $this->getPageTitle()->getLocalURL(),
];
$params['title'] = $this->getPageTitle()->getPrefixedText();
$params['token'] = $this->getContext()->getCsrfTokenSet()->getToken();
$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() .
Html::submitButton(
$this->msg( 'tpt-generic-button' )->text(),
[ 'class' => 'mw-ui-button mw-ui-progressive' ]
) .
Html::closeElement( 'form' )
);
}
private function showUnlinkConfirmation( Title $target ): void {
$formParams = [
'method' => 'post',
'action' => $this->getPageTitle()->getLocalURL(),
];
$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->getContext()->getCsrfTokenSet()->getToken() ) .
$this->msg( 'tpt-unlink-confirm', $target->getPrefixedText() )->parseAsBlock() .
Html::submitButton(
$this->msg( 'tpt-unlink-button' )->text(),
[ 'class' => 'mw-ui-button mw-ui-destructive' ]
) .
Html::closeElement( 'form' )
);
}
/**
* TODO: Move this function to SyncTranslatableBundleStatusMaintenanceScript once we
* start using the translatable_bundles table for fetching the translatabale pages
*/
public static function loadPagesFromDB(): IResultWrapper {
$dbr = Utilities::getSafeReadDB();
return $dbr->newSelectQueryBuilder()
->select( [
'page_id',
'page_namespace',
'page_title',
'page_latest',
'rt_revision' => 'MAX(rt_revision)',
'rt_type'
] )
->from( 'page' )
->join( 'revtag', null, 'page_id=rt_page' )
->where( [
'rt_type' => [ RevTagStore::TP_MARK_TAG, RevTagStore::TP_READY_TAG ],
] )
->orderBy( [ 'page_namespace', 'page_title' ] )
->groupBy( [ 'page_id', 'page_namespace', 'page_title', 'page_latest', 'rt_type' ] )
->caller( __METHOD__ )
->fetchResultSet();
}
/**
* TODO: Move this function to SyncTranslatableBundleStatusMaintenanceScript once we
* start using the translatable_bundles table for fetching the translatabale pages
*/
public static 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 = $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 {
$out = [
// The ideal state for pages: marked and up to date
'active' => [],
'proposed' => [],
'outdated' => [],
'broken' => [],
];
if ( $pages === [] ) {
return $out;
}
// 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 = $this->messageGroupMetadata->loadBasicMetadataForTranslatablePages(
$messageGroupIdsForPreload,
[ 'transclusion', 'version' ]
);
foreach ( $pages as $page ) {
$groupId = $page['groupid'];
$group = MessageGroups::getGroup( $groupId );
$page['discouraged'] = MessageGroups::getPriority( $group ) === 'discouraged';
$page['version'] = $metadata[$groupId]['version'] ?? TranslatablePageMarker::DEFAULT_SYNTAX_VERSION;
$page['transclusion'] = $metadata[$groupId]['transclusion'] ?? false;
// TODO: Eventually we should query the status directly from the TranslatableBundleStore
$tpStatus = TranslatablePage::determineStatus(
$page[RevTagStore::TP_READY_TAG] ?? null,
$page[RevTagStore::TP_MARK_TAG] ?? null,
$page['latest']
);
if ( !$tpStatus ) {
// Ignore pages for which status could not be determined.
continue;
}
$out[self::DISPLAY_STATUS_MAPPING[$tpStatus->getId()]][] = $page;
}
return $out;
}
public function listPages(): void {
$out = $this->getOutput();
$res = self::loadPagesFromDB();
$allPages = self::buildPageArray( $res );
$pagesWithProposedState = [];
if ( $this->translatablePageView->isTranslationBannerNamespaceConfigured() ) {
$pagesWithProposedState = $this->translatablePageStateStore->getRequested();
}
if ( !count( $allPages ) && !count( $pagesWithProposedState ) ) {
$out->addWikiMsg( 'tpt-list-nopages' );
return;
}
$lb = $this->linkBatchFactory->newLinkBatch();
$lb->setCaller( __METHOD__ );
foreach ( $allPages as $page ) {
$lb->addObj( $page['title'] );
}
foreach ( $pagesWithProposedState as $title ) {
$lb->addObj( $title );
}
$lb->execute();
$types = $this->classifyPages( $allPages );
$pages = $types['proposed'];
if ( $pages || $pagesWithProposedState ) {
$out->wrapWikiMsg( '== $1 ==', 'tpt-new-pages-title' );
if ( $pages ) {
$out->addWikiMsg( 'tpt-new-pages', count( $pages ) );
$out->addHTML( $this->getPageList( $pages, 'proposed' ) );
}
if ( $pagesWithProposedState ) {
$out->addWikiMsg( 'tpt-proposed-state-pages', count( $pagesWithProposedState ) );
$out->addHTML( $this->displayPagesWithProposedState( $pagesWithProposedState ) );
}
}
$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>';
}
private function showPage( TranslatablePageMarkOperation $operation ): void {
$page = $operation->getPage();
$out = $this->getOutput();
$out->addWikiMsg( 'tpt-showpage-intro' );
$this->addPageForm(
$page->getTitle(),
'mw-tpt-sp-markform',
'mark',
$page->getRevision()
);
$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,
// unless the page is a template. T305240
$defaultChecked = (
$operation->isFirstMark() &&
!$page->getTitle()->inNamespace( NS_TEMPLATE )
) || $page->hasPageDisplayTitle();
$sourceLanguage = $this->languageFactory->getLanguage( $page->getSourceLanguageCode() );
// Check if there are changed units
if ( array_filter(
$operation->getUnits(),
static fn ( $unit ) => $unit->type === 'changed'
) ) {
// General Area
$dropdown = new FieldLayout(
new DropdownInputWidget( [
'name' => 'unit-fuzzy-selector',
'options' => [
[
'data' => 'all',
'label' => $this->msg( 'tpt-fuzzy-select-all' )->text()
],
[
'data' => 'none',
'label' => $this->msg( 'tpt-fuzzy-select-none' )->text()
],
[
'data' => 'custom',
'label' => $this->msg( 'tpt-fuzzy-select-custom' )->text()
]
],
'value' => 'custom'
] ),
[
'label' => $this->msg( 'tpt-fuzzy-select-label' )->text(),
'align' => 'left',
]
);
$out->addHTML( MessageWebImporter::makeSectionElement(
$this->msg( 'tpt-general-area-header' )->text(),
'dropdown',
$dropdown->toString()
) );
}
foreach ( $operation->getUnits() 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();
$checkLabel = new FieldLayout(
new CheckboxInputWidget( [
'name' => 'tpt-sect-fuzzy[]',
'value' => $s->id,
'selected' => !$s->onlyTvarsChanged()
] ),
[
'label' => $this->msg( 'tpt-action-fuzzy' )->text(),
'align' => 'inline',
'classes' => [ 'mw-tpt-m-vertical', 'mw-tpt-action-field' ],
]
);
$text = $checkLabel->toString() . $text;
} else {
$text = Utilities::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 UnexpectedValueException(
"Unknown severity: $severity for key: {$issue->getKey()}"
);
}
$out->addHTML( $box );
}
}
if ( $operation->getDeletedUnits() ) {
$hasChanges = true;
$out->wrapWikiMsg( '==$1==', 'tpt-sections-deleted' );
foreach ( $operation->getDeletedUnits() as $s ) {
$name = $this->msg( 'tpt-section-deleted', $s->id )->escaped();
$text = Utilities::convertWhiteSpaceToHTML( $s->getText() );
$out->addHTML( MessageWebImporter::makeSectionElement(
$name,
'deleted',
$text,
$sourceLanguage
) );
}
}
// Display template changes if applicable
$markedTag = $page->getMarkedTag();
if ( $markedTag !== null ) {
$hasChanges = true;
$newTemplate = $operation->getParserOutput()->sourcePageTemplateForDiffs();
$oldPage = TranslatablePage::newFromRevision(
$page->getTitle(),
$markedTag
);
$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, $page->supportsTransclusion() ?? $operation->isFirstMark() );
$version = $this->messageGroupMetadata->getWithDefaultValue(
$page->getMessageGroupId(), 'version', TranslatablePageMarker::DEFAULT_SYNTAX_VERSION
);
$this->syntaxVersionForm( $version, $operation->isFirstMark() );
$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)$this->messageGroupMetadata->get( $groupId, 'prioritylangs' );
$default = $storedLanguages !== '' ? explode( ',', $storedLanguages ) : [];
$priorityReason = $this->messageGroupMetadata->get( $groupId, 'priorityreason' );
$priorityReason = $priorityReason !== false ? $priorityReason : '';
$form = new FieldsetLayout( [
'items' => [
new FieldLayout(
new LanguagesMultiselectWidget( [
'infusable' => true,
'name' => 'prioritylangs',
'id' => 'mw-translate-SpecialPageTranslation-prioritylangs',
'languages' => Utilities::getLanguageNames( $interfaceLanguage ),
'default' => $default,
] ),
[
'label' => $this->msg( 'tpt-select-prioritylangs' )->text(),
'align' => 'top',
]
),
new FieldLayout(
new CheckboxInputWidget( [
'name' => 'forcelimit',
'selected' => $this->messageGroupMetadata->get( $groupId, 'priorityforce' ) === 'on',
] ),
[
'label' => $this->msg( 'tpt-select-prioritylangs-force' )->text(),
'align' => 'inline',
'help' => new HtmlSnippet( $this->msg( 'tpt-select-no-prioritylangs-force' )->parse() ),
]
),
new FieldLayout(
new TextInputWidget( [
'name' => 'priorityreason',
'value' => $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 === TranslatablePageMarker::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( TranslatablePage $page, bool $supportsTransclusion ): void {
$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',
'help' => $out->msg( 'tpt-transclusion-help' )
->params( $page->getTitle()->getSubpage( 'de' )->getPrefixedText() )
->text(),
'helpInline' => true,
]
);
$out->addHTML( $checkBox->toString() );
}
private function getPriorityLanguage( WebRequest $request ): array {
// 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.
$priorityLanguages = rtrim( trim( $request->getVal( 'prioritylangs', '' ) ), ',' );
$priorityLanguages = str_replace( "\n", ',', $priorityLanguages );
$priorityLanguages = array_map( 'trim', explode( ',', $priorityLanguages ) );
$priorityLanguages = array_unique( array_filter( $priorityLanguages ) );
$forcePriorityLanguage = $request->getCheck( 'forcelimit' );
$priorityLanguageReason = trim( $request->getText( 'priorityreason' ) );
return [ $priorityLanguages, $forcePriorityLanguage, $priorityLanguageReason ];
}
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'] !== TranslatablePageMarker::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] ??= $this->msg( 'parentheses' )
->rawParams( $this->getLanguage()->pipeList( $tags ) )
->escaped();
$tagList = Html::rawElement(
'span',
[ 'class' => 'mw-tpt-actions' ],
$tagsTextCache[$tagsKey]
);
}
$items[] = "<li class='mw-tpt-pagelist-item'>$link $tagList $acts</li>";
}
return '<ol>' . implode( '', $items ) . '</ol>';
}
/** @param PageRecord[] $pagesWithProposedState */
private function displayPagesWithProposedState( array $pagesWithProposedState ): string {
$items = [];
$preparePageAction = $this->msg( 'tpt-prepare-page' )->text();
$preparePageTooltip = $this->msg( 'tpt-prepare-page-tooltip' )->text();
$linkRenderer = $this->getLinkRenderer();
foreach ( $pagesWithProposedState as $pageRecord ) {
$link = $linkRenderer->makeKnownLink( $pageRecord );
$action = $linkRenderer->makeKnownLink(
SpecialPage::getTitleFor( 'PagePreparation' ),
$preparePageAction,
[ 'title' => $preparePageTooltip ],
[ 'page' => ( Title::newFromPageReference( $pageRecord ) )->getPrefixedText() ]
);
$items[] = "<li class='mw-tpt-pagelist-item'>$link <div>$action</div></li>";
}
return '<ol>' . implode( '', $items ) . '</ol>';
}
private function showTranslationSettings( Title $target, ?ErrorPageError $block ): void {
$out = $this->getOutput();
$out->setPageTitle( $this->msg( 'tpt-translation-settings-page-title' )->text() );
$currentState = $this->translatablePageStateStore->get( $target );
if ( !$this->translatablePageView->canManageTranslationSettings( $target, $this->getUser() ) ) {
$out->wrapWikiMsg( Html::errorBox( '$1' ), 'tpt-translation-settings-restricted' );
$out->addWikiMsg( 'tpt-list-pages-in-translations' );
return;
}
if ( $block ) {
$out->wrapWikiMsg( Html::errorBox( '$1' ), $block->getMessageObject() );
}
if ( $currentState ) {
$this->displayStateInfoMessage( $target, $currentState );
}
$this->addPageForm( $target, 'mw-tpt-sp-settings', 'settings', null );
$out->addHTML(
Html::rawElement(
'p',
[ 'class' => 'mw-tpt-vm' ],
Html::element( 'strong', [], $this->msg( 'tpt-translation-settings-subtitle' ) )
)
);
$currentStateId = $currentState ? $currentState->getStateId() : null;
$options = new FieldsetLayout( [
'items' => [
new FieldLayout(
new RadioInputWidget( [
'name' => 'translatable-page-state',
'value' => 'ignored',
'selected' => $currentStateId === TranslatableBundleState::IGNORE
] ),
[
'label' => $this->msg( 'tpt-translation-settings-ignore' )->text(),
'align' => 'inline',
'help' => $this->msg( 'tpt-translation-settings-ignore-hint' )->text(),
'helpInline' => true,
]
),
new FieldLayout(
new RadioInputWidget( [
'name' => 'translatable-page-state',
'value' => 'unstable',
'selected' => $currentStateId === null
] ),
[
'label' => $this->msg( 'tpt-translation-settings-unstable' )->text(),
'align' => 'inline',
'help' => $this->msg( 'tpt-translation-settings-unstable-hint' )->text(),
'helpInline' => true,
]
),
new FieldLayout(
new RadioInputWidget( [
'name' => 'translatable-page-state',
'value' => 'proposed',
'selected' => $currentStateId === TranslatableBundleState::PROPOSE
] ),
[
'label' => $this->msg( 'tpt-translation-settings-propose' )->text(),
'align' => 'inline',
'help' => $this->msg( 'tpt-translation-settings-propose-hint' )->text(),
'helpInline' => true,
]
),
],
] );
$out->addHTML( $options->toString() );
$submitButton = new FieldLayout(
new ButtonInputWidget( [
'label' => $this->msg( 'tpt-translation-settings-save' )->text(),
'type' => 'submit',
'flags' => [ 'primary', 'progressive' ],
'disabled' => $block !== null,
] )
);
$out->addHTML( $submitButton->toString() );
$out->addHTML( Html::closeElement( 'form' ) );
}
private function handleTranslationState( Title $title, string $selectedState ): void {
$validStateValues = [ 'ignored', 'unstable', 'proposed' ];
$out = $this->getOutput();
if ( !in_array( $selectedState, $validStateValues ) ) {
throw new InvalidArgumentException( "Invalid translation state selected: $selectedState" );
}
$user = $this->getUser();
if ( !$this->translatablePageView->canManageTranslationSettings( $title, $user ) ) {
$this->showTranslationStateRestricted();
return;
}
$bundleState = TranslatableBundleState::newFromText( $selectedState );
if ( $selectedState === 'unstable' ) {
$this->translatablePageStateStore->remove( $title );
} else {
$this->translatablePageStateStore->set( $title, $bundleState );
}
$this->displayStateInfoMessage( $title, $bundleState );
$out->setPageTitle( $this->msg( 'tpt-translation-settings-page-title' )->text() );
$out->addWikiMsg( 'tpt-list-pages-in-translations' );
}
private function addPageForm(
Title $target,
string $formClass,
string $action,
?int $revision
): void {
$formParams = [
'method' => 'post',
'action' => $this->getPageTitle()->getLocalURL(),
'class' => $formClass
];
$this->getOutput()->addHTML(
Xml::openElement( 'form', $formParams ) .
Html::hidden( 'do', $action ) .
Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
( $revision ? Html::hidden( 'revision', $revision ) : '' ) .
Html::hidden( 'target', $target->getPrefixedText() ) .
Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() )
);
}
private function displayStateInfoMessage( Title $title, TranslatableBundleState $bundleState ): void {
$stateId = $bundleState->getStateId();
if ( $stateId === TranslatableBundleState::UNSTABLE ) {
$infoMessage = $this->msg( 'tpt-translation-settings-unstable-notice' );
} elseif ( $stateId === TranslatableBundleState::PROPOSE ) {
$userHasPageTranslationRight = $this->getUser()->isAllowed( 'pagetranslation' );
if ( $userHasPageTranslationRight ) {
$infoMessage = $this->msg( 'tpt-translation-settings-proposed-pagetranslation-notice' )->params(
'https://www.mediawiki.org/wiki/Special:MyLanguage/' .
'Help:Extension:Translate/Page_translation_administration',
$title->getFullURL( 'action=edit' ),
SpecialPage::getTitleFor( 'PagePreparation' )
->getFullURL( [ 'page' => $title->getPrefixedText() ] )
);
} else {
$infoMessage = $this->msg( 'tpt-translation-settings-proposed-editor-notice' );
}
} else {
$infoMessage = $this->msg( 'tpt-translation-settings-ignored-notice' );
}
$this->getOutput()->wrapWikiMsg( Html::noticeBox( '$1', '' ), $infoMessage );
}
private function getBlock( WebRequest $request, User $user, Title $title ): ?ErrorPageError {
if ( $this->permissionManager->isBlockedFrom( $user, $title, !$request->wasPosted() ) ) {
$block = $user->getBlock();
if ( $block ) {
return new UserBlockedError(
$block,
$user,
$this->getLanguage(),
$request->getIP()
);
}
return new PermissionsError( 'pagetranslation', [ 'badaccess-group0' ] );
}
return null;
}
private function showTranslationStateRestricted(): void {
$out = $this->getOutput();
$out->wrapWikiMsg( Html::errorBox( "$1" ), 'tpt-translation-settings-restricted' );
$out->addWikiMsg( 'tpt-list-pages-in-translations' );
}
}