src/PageTranslation/MoveTranslatableBundleMaintenanceScript.php
<?php
declare( strict_types = 1 );
namespace MediaWiki\Extension\Translate\PageTranslation;
use Closure;
use MediaWiki\Context\RequestContext;
use MediaWiki\Extension\Translate\Services;
use MediaWiki\Extension\Translate\Utilities\BaseMaintenanceScript;
use MediaWiki\Language\FormatterFactory;
use MediaWiki\MediaWikiServices;
use MediaWiki\Message\Message;
use MediaWiki\Status\Status;
use MediaWiki\Title\MalformedTitleException;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleParser;
use SplObjectStorage;
class MoveTranslatableBundleMaintenanceScript extends BaseMaintenanceScript {
private TranslatableBundleMover $bundleMover;
private TitleParser $titleParser;
private FormatterFactory $formatterFactory;
public function __construct() {
parent::__construct();
$this->addDescription( 'Review and move translatable bundles including their subpages' );
$this->addArg(
'current-page',
' Current name of the page representing a translatable bundle',
self::REQUIRED
);
$this->addArg(
'new-page',
'New translatable bundle name',
self::REQUIRED
);
$this->addArg(
'user',
'User performing the move',
self::REQUIRED
);
$this->addOption(
'reason',
'Reason for moving the translatable bundle',
self::OPTIONAL,
self::HAS_ARG
);
$this->addOption(
'skip-redirect',
'Skip leaving a redirect behind for translatable bundle, subpages and related talk pages',
);
$this->addOption(
'skip-subpages',
'Skip moving subpages under the current page'
);
$this->addOption(
'skip-talkpages',
'Skip moving talk pages under pages being moved'
);
$this->requireExtension( 'Translate' );
}
/** @inheritDoc */
public function execute() {
$this->bundleMover = Services::getInstance()->getTranslatableBundleMover();
$mwService = MediaWikiServices::getInstance();
$this->titleParser = $mwService->getTitleParser();
$this->formatterFactory = $mwService->getFormatterFactory();
$currentBundleName = $this->getArg( 0 );
$newBundleName = $this->getArg( 1 );
$username = $this->getArg( 2 );
$reason = $this->getOption( 'reason', '' );
$leaveRedirect = !$this->hasOption( 'skip-redirect' );
$moveSubpages = !$this->hasOption( 'skip-subpages' );
$moveTalkpages = !$this->hasOption( 'skip-talkpages' );
$userFactory = $mwService->getUserFactory();
$user = $userFactory->newFromName( $username );
if ( $user === null || !$user->isRegistered() ) {
$this->fatalError( "User $username does not exist." );
}
$outputMsg = "Check if '$currentBundleName' can be moved to '$newBundleName'";
$subpageMsg = 'excluding subpages';
if ( $moveSubpages ) {
$subpageMsg = 'including subpages';
}
$talkpageMsg = 'excluding talkpages';
if ( $moveTalkpages ) {
$talkpageMsg = 'including talkpages';
}
$leaveRedirectMsg = 'without leaving redirects';
if ( $leaveRedirect ) {
$leaveRedirectMsg = 'leaving redirects';
}
$this->output( "$outputMsg ($subpageMsg; $talkpageMsg; $leaveRedirectMsg)\n" );
try {
$currentTitle = $this->getTitleFromInput( $currentBundleName ?? '' );
$newTitle = $this->getTitleFromInput( $newBundleName ?? '' );
} catch ( MalformedTitleException $e ) {
$this->error( 'Invalid title: current-bundle or new-bundle' );
$this->fatalError( $e->getMessageObject()->text() );
}
// When moving translatable bundles from script, remove all limits on the number of
// pages that can be moved
$this->bundleMover->disablePageMoveLimit();
try {
$pageCollection = $this->bundleMover->getPageMoveCollection(
$currentTitle,
$newTitle,
$user,
$reason,
$moveSubpages,
$moveTalkpages,
$leaveRedirect
);
} catch ( ImpossiblePageMove $e ) {
$fatalErrorMsg = $this->parseErrorMessage( $e->getBlockers() );
$this->fatalError( $fatalErrorMsg );
}
$this->displayPagesToMove( $pageCollection, $leaveRedirect );
$haveConfirmation = $this->getConfirmation();
if ( !$haveConfirmation ) {
$this->output( "Exiting...\n" );
return;
}
$this->output( "Starting page move\n" );
$pagesToMove = $pageCollection->getListOfPages();
$pagesToRedirect = $pageCollection->getListOfPagesToRedirect();
$this->bundleMover->moveSynchronously(
$currentTitle,
$newTitle,
$pagesToMove,
$pagesToRedirect,
$user,
$reason,
Closure::fromCallable( [ $this, 'progressCallback' ] )
);
$this->logSeparator();
$this->output( "Finished moving '$currentBundleName' to '$newBundleName' $subpageMsg\n" );
}
private function parseErrorMessage( SplObjectStorage $errors ): string {
$errorMsg = wfMessage( 'pt-movepage-blockers', count( $errors ) )->text() . "\n";
foreach ( $errors as $title ) {
$titleText = $title->getPrefixedText();
$errorMsg .= "$titleText\n";
$errorMsg .= $errors[ $title ]->getWikiText( false, 'pt-movepage-error-placeholder', 'en' );
$errorMsg .= "\n";
}
return $errorMsg;
}
private function progressCallback( Title $previous, Title $new, Status $status, int $total, int $processed ): void {
$previousTitleText = $previous->getPrefixedText();
$newTitleText = $new->getPrefixedText();
$paddedProcessed = str_pad( (string)$processed, strlen( (string)$total ), ' ', STR_PAD_LEFT );
$progressCounter = "($paddedProcessed/$total)";
if ( $status->isOK() ) {
$this->output( "$progressCounter $previousTitleText --> $newTitleText\n" );
} else {
$reason = $this->formatterFactory
->getStatusFormatter( RequestContext::getMain() )
->getWikiText( $status );
$this->output( "$progressCounter Failed to move $previousTitleText to $newTitleText\n" );
$this->output( "\tReason: $reason\n" );
}
}
private function displayPagesToMove( PageMoveCollection $pageCollection, bool $leaveRedirect ): void {
$infoMessage = "\nThe following pages will be moved:\n";
$count = 0;
$subpagesCount = 0;
$talkpagesCount = 0;
/** @var PageMoveOperation[][] */
$pagesToMove = [
'pt-movepage-list-source' => [ $pageCollection->getTranslatablePage() ],
'pt-movepage-list-translation' => $pageCollection->getTranslationPagesPair(),
'pt-movepage-list-section' => $pageCollection->getUnitPagesPair()
];
$subpages = $pageCollection->getSubpagesPair();
if ( $subpages ) {
$pagesToMove[ 'pt-movepage-list-other'] = $subpages;
}
foreach ( $pagesToMove as $type => $pages ) {
$lines = [];
$infoMessage .= $this->getSectionHeader( $type, $pages, $leaveRedirect );
if ( !count( $pages ) ) {
continue;
}
foreach ( $pages as $pagePairs ) {
$count++;
if ( $type === 'pt-movepage-list-other' ) {
$subpagesCount++;
}
$old = $pagePairs->getOldTitle();
$new = $pagePairs->getNewTitle();
if ( $new ) {
$line = '* ' . $old->getPrefixedText() . ' → ' . $new->getPrefixedText();
if ( $pagePairs->hasTalkpage() ) {
$count++;
$talkpagesCount++;
$line .= ' ' . $this->message( 'pt-movepage-talkpage-exists' )->text();
}
$lines[] = $line;
}
}
$infoMessage .= implode( "\n", $lines ) . "\n";
}
$translatableSubpages = $pageCollection->getTranslatableSubpages();
$infoMessage .= $this->getSectionHeader(
'pt-movepage-list-translatable', $translatableSubpages, $leaveRedirect
);
if ( $translatableSubpages ) {
$lines = [];
$infoMessage .= $this->message( 'pt-movepage-list-translatable-note' )->text() . "\n";
foreach ( $translatableSubpages as $page ) {
$lines[] = '* ' . $page->getPrefixedText();
}
$infoMessage .= implode( "\n", $lines ) . "\n";
}
$nonMovableSubpages = $pageCollection->getNonMovableSubpages();
if ( $nonMovableSubpages ) {
$lines = [];
$infoMessage .= $this->getSectionHeader(
'pt-movepage-list-nonmovable', $nonMovableSubpages, $leaveRedirect
);
$infoMessage .= $this->message( 'pt-movepage-list-nonmovable-note' )
->numParams( count( $nonMovableSubpages ) )->text() . "\n";
foreach ( $nonMovableSubpages as $page => $status ) {
$invalidityReason = $this->formatterFactory
->getStatusFormatter( RequestContext::getMain() )
->getWikiText( $status );
$lines[] = '* ' . $page . ' (' . str_replace( "\n", " ", $invalidityReason ) . ')';
}
$infoMessage .= implode( "\n", $lines ) . "\n";
}
$this->output( $infoMessage );
$this->logSeparator();
$this->output(
$this->message( 'pt-movepage-list-count' )
->numParams( $count, $subpagesCount, $talkpagesCount )
->text() . "\n"
);
$this->logSeparator();
$this->output( "\n" );
}
private function getSectionHeader( string $type, array $pages, bool $leaveRedirect ): string {
$infoMessage = $this->getSeparator();
$pageCount = count( $pages );
$shouldRedirect = TranslatableBundleMover::shouldLeaveRedirect( $type, $leaveRedirect );
// $type can be: pt-movepage-list-source, pt-movepage-list-translation, pt-movepage-list-section
// pt-movepage-list-other
$infoMessage .= $this->message( $type )->numParams( $pageCount )->text() . ' ';
if ( $shouldRedirect ) {
$infoMessage .= '(leave redirect)';
}
$infoMessage .= "\n\n";
if ( !$pageCount ) {
$infoMessage .= $this->message( 'pt-movepage-list-no-pages' )->text() . "\n";
}
return $infoMessage;
}
private function getConfirmation(): bool {
$line = self::readconsole( 'Type "MOVE" to begin the move operation: ' );
return strtolower( $line ) === 'move';
}
private function getSeparator( int $width = 15 ): string {
return str_repeat( '-', $width ) . "\n";
}
private function logSeparator( int $width = 15 ): void {
$this->output( $this->getSeparator( $width ) );
}
private function message( string $key ): Message {
return ( new Message( $key ) )->inLanguage( 'en' );
}
private function getTitleFromInput( string $pageName ): Title {
$titleValue = $this->titleParser->parseTitle( $pageName );
return Title::newFromLinkTarget( $titleValue );
}
}