diff options
Diffstat (limited to 'MLEB/Translate/src/PageTranslation')
21 files changed, 2609 insertions, 263 deletions
diff --git a/MLEB/Translate/src/PageTranslation/ImpossiblePageMove.php b/MLEB/Translate/src/PageTranslation/ImpossiblePageMove.php new file mode 100644 index 00000000..425cef7b --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/ImpossiblePageMove.php @@ -0,0 +1,27 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Exception; +use SplObjectStorage; + +/** + * Exception thrown when a translatable page move is not possible + * @author Abijeet Patro + * @license GPL-2.0-or-later + * @since 2021.09 + */ +class ImpossiblePageMove extends Exception { + /** @var SplObjectStorage */ + private $blockers; + + public function __construct( SplObjectStorage $blockers ) { + parent::__construct(); + $this->blockers = $blockers; + } + + public function getBlockers(): SplObjectStorage { + return $this->blockers; + } +} diff --git a/MLEB/Translate/src/PageTranslation/InvalidPageTitleRename.php b/MLEB/Translate/src/PageTranslation/InvalidPageTitleRename.php new file mode 100644 index 00000000..4fbb01ef --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/InvalidPageTitleRename.php @@ -0,0 +1,15 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Exception; + +/** + * Exception thrown when a rename for a title fails + * @author Abijeet Patro + * @license GPL-2.0-or-later + * @since 2021.09 + */ +class InvalidPageTitleRename extends Exception { +} diff --git a/MLEB/Translate/src/PageTranslation/MoveTranslatablePageMaintenanceScript.php b/MLEB/Translate/src/PageTranslation/MoveTranslatablePageMaintenanceScript.php index 1314e0e4..29f4d531 100644 --- a/MLEB/Translate/src/PageTranslation/MoveTranslatablePageMaintenanceScript.php +++ b/MLEB/Translate/src/PageTranslation/MoveTranslatablePageMaintenanceScript.php @@ -13,8 +13,6 @@ use SplObjectStorage; use Status; use Title; use TitleParser; -use TranslatablePage; -use TranslateUtils; class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { /** @var TranslatablePageMover */ @@ -52,8 +50,13 @@ class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { ); $this->addOption( - 'move-subpages', - 'Move subpages under the current page' + 'skip-subpages', + 'Skip moving subpages under the current page' + ); + + $this->addOption( + 'skip-talkpages', + 'Skip moving talk pages under pages being moved' ); $this->requireExtension( 'Translate' ); @@ -70,29 +73,28 @@ class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { $newPagename = $this->getArg( 1 ); $username = $this->getArg( 2 ); $reason = $this->getOption( 'reason', '' ); - $moveSubpages = $this->hasOption( 'move-subpages' ); + $moveSubpages = !$this->hasOption( 'skip-subpages' ); + $moveTalkpages = !$this->hasOption( 'skip-talkpages' ); - if ( is_callable( [ $mwService, 'getUserFactory' ] ) ) { - // MW 1.35+ - $userFactory = $mwService->getUserFactory(); - $user = $userFactory->newFromName( $username ); - } else { - $user = \User::newFromName( $username ); - // Set to null if $user is false - $user = $user ?: null; - } + $userFactory = $mwService->getUserFactory(); + $user = $userFactory->newFromName( $username ); if ( $user === null || !$user->isRegistered() ) { $this->fatalError( "User $username does not exist." ); } $outputMsg = "Check if '$currentPagename' can be moved to '$newPagename'"; - $subpageMsg = '(excluding subpages)'; + $subpageMsg = 'excluding subpages'; if ( $moveSubpages ) { - $subpageMsg = '(including subpages)'; + $subpageMsg = 'including subpages'; } - $this->output( "$outputMsg $subpageMsg\n" ); + $talkpageMsg = 'excluding talkpages'; + if ( $moveTalkpages ) { + $talkpageMsg = 'including talkpages'; + } + + $this->output( "$outputMsg ($subpageMsg; $talkpageMsg)\n" ); try { $currentTitle = $this->getTitleFromInput( $currentPagename ?? '' ); @@ -105,21 +107,21 @@ class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { // When moving translatable pages from script, remove all limits on the number of // pages that can be moved $this->pageMover->disablePageMoveLimit(); - $blockers = $this->pageMover->checkMoveBlockers( - $currentTitle, - $newTitle, - $user, - $reason, - $moveSubpages - ); - - if ( count( $blockers ) ) { - $fatalErrorMsg = $this->parseErrorMessage( $blockers ); + try { + $pageCollection = $this->pageMover->getPageMoveCollection( + $currentTitle, + $newTitle, + $user, + $reason, + $moveSubpages, + $moveTalkpages + ); + } catch ( ImpossiblePageMove $e ) { + $fatalErrorMsg = $this->parseErrorMessage( $e->getBlockers() ); $this->fatalError( $fatalErrorMsg ); } - $groupedPagesToMove = $this->getGroupedPagesToMove( $currentTitle ); - $this->displayPagesToMove( $currentTitle, $newTitle, $groupedPagesToMove ); + $this->displayPagesToMove( $pageCollection ); $haveConfirmation = $this->getConfirmation(); if ( !$haveConfirmation ) { @@ -129,7 +131,7 @@ class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { $this->output( "Starting page move\n" ); - $pagesToMove = $this->pageMover->getPagesToMove( $currentTitle, $newTitle, $moveSubpages ); + $pagesToMove = $pageCollection->getListOfPages(); $this->pageMover->moveSynchronously( $currentTitle, @@ -170,77 +172,95 @@ class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { } } - /** @return Title[][] */ - private function getGroupedPagesToMove( Title $source ): array { - $page = TranslatablePage::newFromTitle( $source ); + private function displayPagesToMove( PageMoveCollection $pageCollection ): void { + $infoMessage = "\nThe following pages will be moved:\n"; + $count = 0; + $subpagesCount = 0; + $talkpagesCount = 0; - $types = [ - 'pt-movepage-list-pages' => [ $source ], - 'pt-movepage-list-translation' => $page->getTranslationPages(), - 'pt-movepage-list-section' => $page->getTranslationUnitPages( 'all' ), - 'pt-movepage-list-translatable' => $this->pageMover->getTranslatableSubpages( $page ) + /** @var PageMoveOperation[][] */ + $pagesToMove = [ + 'pt-movepage-list-pages' => [ $pageCollection->getTranslatablePage() ], + 'pt-movepage-list-translation' => $pageCollection->getTranslationPagesPair(), + 'pt-movepage-list-section' => $pageCollection->getUnitPagesPair() ]; - if ( TranslateUtils::allowsSubpages( $source ) ) { - $types[ 'pt-movepage-list-other'] = $this->pageMover->getNormalSubpages( $page ); + $subpages = $pageCollection->getSubpagesPair(); + if ( $subpages ) { + $pagesToMove[ 'pt-movepage-list-other'] = $subpages; } - return $types; - } - - private function displayPagesToMove( Title $currentTitle, Title $newTitle, array $pagesToMove ): void { - $infoMessage = "\nThe following pages will be moved:\n"; - $count = 0; - $subpagesCount = 0; - $base = $currentTitle->getPrefixedText(); - foreach ( $pagesToMove as $type => $pages ) { - $infoMessage .= $this->getSeparator(); - $pageCount = count( $pages ); - $infoMessage .= $this->message( $type )->numParams( $pageCount )->text() . "\n\n"; - if ( !$pageCount ) { - $infoMessage .= $this->message( 'pt-movepage-list-no-pages' )->text() . "\n"; + $lines = []; + $infoMessage .= $this->getSectionHeader( $type, $pages ); + if ( !count( $pages ) ) { continue; } - if ( $type === 'pt-movepage-list-translatable' ) { - $infoMessage .= $this->message( 'pt-movepage-list-translatable-note' )->text() . "\n"; - } - - $canBeMoved = $type !== 'pt-movepage-list-translatable'; - $lines = []; - foreach ( $pages as $currentPage ) { - if ( $canBeMoved ) { - $count++; - } + foreach ( $pages as $pagePairs ) { + $count++; if ( $type === 'pt-movepage-list-other' ) { $subpagesCount++; } - if ( $canBeMoved ) { - $to = $this->pageMover->newPageTitle( $base, $currentPage, $newTitle ); - $lines[] = '* ' . $currentPage->getPrefixedText() . ' → ' . $to; - } else { - $lines[] = '* ' . $currentPage->getPrefixedText(); + $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 ); + + 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"; + } + $this->output( $infoMessage ); $this->logSeparator(); $this->output( $this->message( 'pt-movepage-list-count' ) - ->numParams( $count, $subpagesCount ) + ->numParams( $count, $subpagesCount, $talkpagesCount ) ->text() . "\n" ); $this->logSeparator(); $this->output( "\n" ); } + private function getSectionHeader( string $type, array $pages ): string { + $infoMessage = $this->getSeparator(); + $pageCount = count( $pages ); + + // $type can be: pt-movepage-list-pages, pt-movepage-list-translation, pt-movepage-list-section + // pt-movepage-list-other + $infoMessage .= $this->message( $type )->numParams( $pageCount )->text() . "\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'; diff --git a/MLEB/Translate/src/PageTranslation/MoveTranslatablePageSpecialPage.php b/MLEB/Translate/src/PageTranslation/MoveTranslatablePageSpecialPage.php new file mode 100644 index 00000000..1197aa31 --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/MoveTranslatablePageSpecialPage.php @@ -0,0 +1,402 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use CommentStore; +use ErrorPageError; +use Html; +use HTMLForm; +use MediaWiki\Permissions\PermissionManager; +use OutputPage; +use PermissionsError; +use ReadOnlyError; +use SplObjectStorage; +use ThrottledError; +use Title; +use TranslatablePage; +use UnlistedSpecialPage; +use Wikimedia\ObjectFactory; + +/** + * Replacement for Special:Movepage to allow renaming a translatable page and + * all pages associated with it. + * + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @ingroup SpecialPage PageTranslation + */ +class MoveTranslatablePageSpecialPage extends UnlistedSpecialPage { + // Form parameters both as text and as titles + /** @var string */ + private $oldText; + /** @var string */ + private $reason; + /** @var bool */ + private $moveTalkpages = true; + /** @var bool */ + private $moveSubpages = true; + // Dependencies + /** @var ObjectFactory */ + private $objectFactory; + /** @var TranslatablePageMover */ + private $pageMover; + /** @var PermissionManager */ + private $permissionManager; + private $movePageSpec; + // Other + /** @var Title */ + private $oldTitle; + + public function __construct( + ObjectFactory $objectFactory, + PermissionManager $permissionManager, + TranslatablePageMover $pageMover, + $movePageSpec + ) { + parent::__construct( 'Movepage' ); + $this->objectFactory = $objectFactory; + $this->permissionManager = $permissionManager; + $this->pageMover = $pageMover; + // SpecialMovepage started using service injection in + // I6d4fe09891a126d803fee90bc3bb4959e8b29eb9 + // Needed for MW < 1.36 + if ( is_string( $movePageSpec ) ) { + $this->movePageSpec = [ 'class' => $movePageSpec ]; + } else { + $this->movePageSpec = $movePageSpec; + } + } + + public function doesWrites(): bool { + return true; + } + + protected function getGroupName(): string { + return 'pagetools'; + } + + /** @inheritDoc */ + public function execute( $par ) { + $request = $this->getRequest(); + $user = $this->getUser(); + $this->addHelpLink( 'Help:Extension:Translate/Move_translatable_page' ); + + $this->oldText = $request->getText( 'wpOldTitle', $request->getText( 'target', $par ) ); + $newText = $request->getText( 'wpNewTitle' ); + + $this->oldTitle = Title::newFromText( $this->oldText ); + $newTitle = Title::newFromText( $newText ); + // Normalize input + if ( $this->oldTitle ) { + $this->oldText = $this->oldTitle->getPrefixedText(); + } + + $this->reason = $request->getText( 'reason' ); + + // This will throw exceptions if there is an error. + $this->doBasicChecks(); + + // Real stuff starts here + $page = TranslatablePage::newFromTitle( $this->oldTitle ); + if ( $page->getMarkedTag() !== false ) { + $this->getOutput()->setPageTitle( $this->msg( 'pt-movepage-title', $this->oldText ) ); + + if ( !$user->isAllowed( 'pagetranslation' ) ) { + throw new PermissionsError( 'pagetranslation' ); + } + + // Is there really no better way to do this? + $subactionText = $request->getText( 'subaction' ); + switch ( $subactionText ) { + case $this->msg( 'pt-movepage-action-check' )->text(): + $subaction = 'check'; + break; + case $this->msg( 'pt-movepage-action-perform' )->text(): + $subaction = 'perform'; + break; + case $this->msg( 'pt-movepage-action-other' )->text(): + $subaction = ''; + break; + default: + $subaction = ''; + } + + if ( $subaction === 'check' && $this->checkToken() && $request->wasPosted() ) { + try { + $pageCollection = $this->pageMover->getPageMoveCollection( + $this->oldTitle, + $newTitle, + $user, + $this->reason, + $this->moveSubpages, + $this->moveTalkpages + ); + } catch ( ImpossiblePageMove $e ) { + $this->showErrors( $e->getBlockers() ); + $this->showForm( [] ); + return; + } + + $this->showConfirmation( $pageCollection ); + } elseif ( $subaction === 'perform' && $this->checkToken() && $request->wasPosted() ) { + $this->moveSubpages = $request->getBool( 'subpages' ); + $this->moveTalkpages = $request->getBool( 'talkpages' ); + + $this->pageMover->moveAsynchronously( + $this->oldTitle, + $newTitle, + $this->moveSubpages, + $this->getUser(), + $this->msg( 'pt-movepage-logreason', $this->oldTitle )->inContentLanguage()->text(), + $this->moveTalkpages + ); + $this->getOutput()->addWikiMsg( 'pt-movepage-started' ); + } else { + $this->showForm( [] ); + } + } else { + // Delegate... don't want to reimplement this + $sp = $this->objectFactory->createObject( $this->movePageSpec ); + $sp->execute( $par ); + } + } + + /** + * Do the basic checks whether moving is possible and whether + * the input looks anywhere near sane. + * @throws PermissionsError|ErrorPageError|ReadOnlyError|ThrottledError + */ + protected function doBasicChecks(): void { + $this->checkReadOnly(); + + if ( $this->oldTitle === null ) { + throw new ErrorPageError( 'notargettitle', 'notargettext' ); + } + + if ( !$this->oldTitle->exists() ) { + throw new ErrorPageError( 'nopagetitle', 'nopagetext' ); + } + + if ( $this->getUser()->pingLimiter( 'move' ) ) { + throw new ThrottledError; + } + + // Check rights + $permErrors = $this->permissionManager + ->getPermissionErrors( 'move', $this->getUser(), $this->oldTitle ); + if ( count( $permErrors ) ) { + throw new PermissionsError( 'move', $permErrors ); + } + } + + /** + * Checks token to protect against CSRF. + * + * FIXME: make this a form special page instead of manually checking stuff? + * @return bool + */ + protected function checkToken(): bool { + return $this->getUser()->matchEditToken( $this->getRequest()->getVal( 'wpEditToken' ) ); + } + + /** + * Pretty-print the list of errors. + * @param SplObjectStorage $errors Array with message key and parameters + */ + protected function showErrors( SplObjectStorage $errors ): void { + $out = $this->getOutput(); + + $out->addHTML( Html::openElement( 'div', [ 'class' => 'errorbox' ] ) ); + $out->addWikiMsg( + 'pt-movepage-blockers', + $this->getLanguage()->formatNum( count( $errors ) ) + ); + + // If there are many errors, for performance reasons we must parse them all at once + $s = ''; + $context = 'pt-movepage-error-placeholder'; + foreach ( $errors as $title ) { + $titleText = $title->getPrefixedText(); + $s .= "'''$titleText'''\n\n"; + $s .= $errors[ $title ]->getWikiText( false, $context ); + } + + $out->addWikiTextAsInterface( $s ); + $out->addHTML( Html::closeElement( 'div' ) ); + } + + /** + * The query form. + * @param array $err Unused. + * @param bool $isPermError Unused. + */ + public function showForm( $err, $isPermError = false ): void { + $this->getOutput()->addWikiMsg( 'pt-movepage-intro' ); + + HTMLForm::factory( 'ooui', $this->getCommonFormFields(), $this->getContext() ) + ->setMethod( 'post' ) + ->setAction( $this->getPageTitle( $this->oldText )->getLocalURL() ) + ->setSubmitName( 'subaction' ) + ->setSubmitTextMsg( 'pt-movepage-action-check' ) + ->setWrapperLegendMsg( 'pt-movepage-legend' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * The second form, which still allows changing some things. + * Lists all the action which would take place. + * @param PageMoveCollection $pageCollection + */ + protected function showConfirmation( PageMoveCollection $pageCollection ): void { + $out = $this->getOutput(); + + $out->addWikiMsg( 'pt-movepage-intro' ); + + $count = 0; + $subpagesCount = 0; + $talkpagesCount = 0; + + /** @var PageMoveOperation[][] */ + $pagesToMove = [ + 'pt-movepage-list-pages' => [ $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 ) { + $this->addSectionHeader( $out, $type, $pages ); + + if ( !$pages ) { + $out->addWikiMsg( 'pt-movepage-list-no-pages' ); + continue; + } + + $lines = []; + + foreach ( $pages as $pagePairs ) { + $count++; + + if ( $type === 'pt-movepage-list-other' ) { + $subpagesCount++; + } + + $old = $pagePairs->getOldTitle(); + $new = $pagePairs->getNewTitle(); + $line = '* ' . $old->getPrefixedText() . ' → ' . $new->getPrefixedText(); + if ( $pagePairs->hasTalkpage() ) { + $count++; + $talkpagesCount++; + $line .= ' ' . $this->msg( 'pt-movepage-talkpage-exists' )->text(); + } + + $lines[] = $line; + } + + $out->addWikiTextAsInterface( implode( "\n", $lines ) ); + } + + $translatableSubpages = $pageCollection->getTranslatableSubpages(); + $sectionType = 'pt-movepage-list-translatable'; + $this->addSectionHeader( $out, $sectionType, $translatableSubpages ); + if ( $translatableSubpages ) { + $lines = []; + $out->wrapWikiMsg( "'''$1'''", $this->msg( 'pt-movepage-list-translatable-note' ) ); + foreach ( $translatableSubpages as $page ) { + $lines[] = '* ' . $page->getPrefixedText(); + } + $out->addWikiTextAsInterface( implode( "\n", $lines ) ); + } + + $out->addWikiTextAsInterface( "----\n" ); + $out->addWikiMsg( + 'pt-movepage-list-count', + $this->getLanguage()->formatNum( $count ), + $this->getLanguage()->formatNum( $subpagesCount ), + $this->getLanguage()->formatNum( $talkpagesCount ) + ); + + $formDescriptor = array_merge( + $this->getCommonFormFields(), + [ + 'subpages' => [ + 'type' => 'check', + 'name' => 'subpages', + 'id' => 'mw-subpages', + 'label-message' => 'pt-movepage-subpages', + 'default' => $this->moveSubpages, + ], + 'talkpages' => [ + 'type' => 'check', + 'name' => 'talkpages', + 'id' => 'mw-talkpages', + 'label-message' => 'pt-movepage-talkpages', + 'default' => $this->moveTalkpages + ] + ] + ); + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm + ->addButton( [ + 'name' => 'subaction', + 'value' => $this->msg( 'pt-movepage-action-other' )->text(), + ] ) + ->setMethod( 'post' ) + ->setAction( $this->getPageTitle( $this->oldText )->getLocalURL() ) + ->setSubmitName( 'subaction' ) + ->setSubmitTextMsg( 'pt-movepage-action-perform' ) + ->setWrapperLegendMsg( 'pt-movepage-legend' ) + ->prepareForm() + ->displayForm( false ); + } + + private function addSectionHeader( OutputPage $out, string $type, array $pages ): void { + $pageCount = count( $pages ); + $out->wrapWikiMsg( '=== $1 ===', [ $type, $pageCount ] ); + + if ( !$pageCount ) { + $out->addWikiMsg( 'pt-movepage-list-no-pages' ); + } + } + + private function getCommonFormFields(): array { + return [ + 'wpOldTitle' => [ + 'type' => 'text', + 'name' => 'wpOldTitle', + 'label-message' => 'pt-movepage-current', + 'default' => $this->oldText, + 'readonly' => true, + ], + 'wpNewTitle' => [ + 'type' => 'text', + 'name' => 'wpNewTitle', + 'label-message' => 'pt-movepage-new', + ], + 'reason' => [ + 'type' => 'text', + 'name' => 'reason', + 'label-message' => 'pt-movepage-reason', + 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT, + 'default' => $this->reason, + ], + 'subpages' => [ + 'type' => 'hidden', + 'name' => 'subpages', + 'default' => $this->moveSubpages, + ], + 'talkpages' => [ + 'type' => 'hidden', + 'name' => 'talkpages', + 'default' => $this->moveTalkpages + ] + ]; + } +} diff --git a/MLEB/Translate/src/PageTranslation/PageMoveCollection.php b/MLEB/Translate/src/PageTranslation/PageMoveCollection.php new file mode 100644 index 00000000..338b914c --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/PageMoveCollection.php @@ -0,0 +1,144 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Title; + +/** + * Collection of pages potentially affected by a page move operation. + * @author Abijeet Patro + * @license GPL-2.0-or-later + * @since 2021.09 + */ +class PageMoveCollection { + /** @var PageMoveOperation|null */ + private $translatablePage; + /** @var PageMoveOperation[] */ + private $translationPagePairs; + /** @var PageMoveOperation[] */ + private $unitPagesPairs; + /** @var PageMoveOperation[] */ + private $subpagesPairs; + /** @var PageMoveOperation[] */ + private $talkpagesPairs; + /** @var Title[] */ + private $translatableSubpages; + + /** + * @param PageMoveOperation $translatablePage Translatable page + * @param PageMoveOperation[] $translationPagePairs Translation pages + * @param PageMoveOperation[] $unitPagesPairs Translation unit pages + * @param PageMoveOperation[] $subpagesPairs Non translatable sub pages + * @param array $translatableSubpages Translatable sub pages + */ + public function __construct( + PageMoveOperation $translatablePage, + array $translationPagePairs, + array $unitPagesPairs, + array $subpagesPairs, + array $translatableSubpages + ) { + $this->translatablePage = $translatablePage; + $this->translationPagePairs = $translationPagePairs; + $this->unitPagesPairs = $unitPagesPairs; + $this->subpagesPairs = $subpagesPairs; + $this->translatableSubpages = $translatableSubpages; + + // Populate the talk pages from the various inputs. + $this->talkpagesPairs = $this->getTalkpages( + $this->translatablePage, ...$translationPagePairs, ...$unitPagesPairs, ...$subpagesPairs + ); + } + + public function getTranslatablePage(): PageMoveOperation { + return $this->translatablePage; + } + + /** @return PageMoveOperation[] */ + public function getTranslationPagesPair(): array { + return $this->translationPagePairs; + } + + /** @return PageMoveOperation[] */ + public function getUnitPagesPair(): array { + return $this->unitPagesPairs; + } + + /** @return PageMoveOperation[] */ + public function getSubpagesPair(): array { + return $this->subpagesPairs; + } + + /** @return Title[] */ + public function getTranslatableSubpages(): array { + return $this->translatableSubpages; + } + + /** @return Title[] */ + public function getTranslationPages(): array { + return $this->getOldPagesFromList( $this->translationPagePairs ); + } + + /** @return Title[] */ + public function getUnitPages(): array { + return $this->getOldPagesFromList( $this->unitPagesPairs ); + } + + /** @return Title[] */ + public function getSubpages(): array { + return $this->getOldPagesFromList( $this->subpagesPairs ); + } + + /** @return string[] */ + public function getListOfPages(): array { + $pageList = [ + $this->translatablePage->getOldTitle()->getPrefixedText() => + $this->translatablePage->getNewTitle() ? + $this->translatablePage->getNewTitle()->getPrefixedText() : null + ]; + $pageList = array_merge( $pageList, $this->getPagePairFromList( $this->translationPagePairs ) ); + $pageList = array_merge( $pageList, $this->getPagePairFromList( $this->unitPagesPairs ) ); + $pageList = array_merge( $pageList, $this->getPagePairFromList( $this->subpagesPairs ) ); + $pageList = array_merge( $pageList, $this->getPagePairFromList( $this->talkpagesPairs ) ); + + return $pageList; + } + + /** + * @param PageMoveOperation[] $pagePairs + * @return Title[] + */ + private function getOldPagesFromList( array $pagePairs ): array { + $oldTitles = []; + foreach ( $pagePairs as $pair ) { + $oldTitles[] = $pair->getOldTitle(); + } + + return $oldTitles; + } + + /** @return string[] */ + private function getPagePairFromList( array $pagePairs ): array { + $pairs = []; + foreach ( $pagePairs as $pair ) { + $pairs[ $pair->getOldTitle()->getPrefixedText() ] = + $pair->getNewTitle() ? $pair->getNewTitle()->getPrefixedText() : null; + } + + return $pairs; + } + + /** @return PageMoveOperation[] */ + private function getTalkpages( PageMoveOperation ...$allMoveOperations ): array { + $talkpagesPairs = []; + foreach ( $allMoveOperations as $moveOperation ) { + if ( $moveOperation->hasTalkpage() ) { + $talkpagesPairs[] = new PageMoveOperation( + $moveOperation->getOldTalkpage(), $moveOperation->getNewTalkpage() + ); + } + } + return $talkpagesPairs; + } +} diff --git a/MLEB/Translate/src/PageTranslation/PageMoveOperation.php b/MLEB/Translate/src/PageTranslation/PageMoveOperation.php new file mode 100644 index 00000000..2cfbb5ea --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/PageMoveOperation.php @@ -0,0 +1,61 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Title; + +/** + * Represents a single page being moved including the talk page. + * @author Abijeet Patro + * @license GPL-2.0-or-later + * @since 2021.09 + */ +class PageMoveOperation { + /** @var Title */ + private $old; + /** @var Title|null */ + private $new; + /** @var Title|null */ + private $oldTalkpage; + /** @var Title|null */ + private $newTalkpage; + /** @var InvalidPageTitleRename|null */ + private $invalidPageTitleRename; + + public function __construct( Title $old, ?Title $new, ?InvalidPageTitleRename $e = null ) { + $this->old = $old; + $this->new = $new; + $this->invalidPageTitleRename = $e; + } + + public function getOldTitle(): Title { + return $this->old; + } + + public function getNewTitle(): ?Title { + return $this->new; + } + + public function getOldTalkpage(): ?Title { + return $this->oldTalkpage; + } + + public function getNewTalkpage(): ?Title { + return $this->newTalkpage; + } + + public function hasTalkpage(): bool { + return $this->oldTalkpage !== null; + } + + public function getRenameErrorCode(): int { + return $this->invalidPageTitleRename ? + $this->invalidPageTitleRename->getCode() : PageTitleRenamer::NO_ERROR; + } + + public function setTalkpage( Title $oldTalkpage, ?Title $newTalkpage ): void { + $this->oldTalkpage = $oldTalkpage; + $this->newTalkpage = $newTalkpage; + } +} diff --git a/MLEB/Translate/src/PageTranslation/PageTitleRenamer.php b/MLEB/Translate/src/PageTranslation/PageTitleRenamer.php new file mode 100644 index 00000000..45ccee6d --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/PageTitleRenamer.php @@ -0,0 +1,105 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Title; + +/** + * Contains logic to determine the new title of translatable pages and + * dependent pages being moved + * @author Niklas Laxström + * @author Abijeet Patro + * @license GPL-2.0-or-later + * @since 2021.09 + */ +class PageTitleRenamer { + public const NO_ERROR = 0; + public const UNKNOWN_PAGE = 1; + public const NS_TALK_UNSUPPORTED = 2; + public const RENAME_FAILED = 3; + public const INVALID_TITLE = 4; + + private const IMPOSSIBLE = null; + private $map = []; + + public function __construct( Title $source, Title $target ) { + $this->map[$source->getNamespace()] = [ + $target->getNamespace(), + $source->getText(), + $target->getText(), + ]; + + $sourceTalkPage = $source->getTalkPageIfDefined(); + $targetTalkPage = $target->getTalkPageIfDefined(); + if ( $sourceTalkPage ) { + if ( !$targetTalkPage ) { + $this->map[$sourceTalkPage->getNamespace()] = [ + self::IMPOSSIBLE, + null, + null, + ]; + } else { + $this->map[$sourceTalkPage->getNamespace()] = [ + $targetTalkPage->getNamespace(), + $source->getText(), + $target->getText(), + ]; + } + } + + $this->map[NS_TRANSLATIONS] = [ + NS_TRANSLATIONS, + $source->getPrefixedText(), + $target->getPrefixedText(), + ]; + + $this->map[NS_TRANSLATIONS_TALK] = [ + NS_TRANSLATIONS_TALK, + $source->getPrefixedText(), + $target->getPrefixedText(), + ]; + } + + public function getNewTitle( Title $title ): Title { + $instructions = $this->map[$title->getNamespace()] ?? null; + if ( $instructions === null ) { + throw new InvalidPageTitleRename( + 'Trying to move a page which is not part of the translatable page', self::UNKNOWN_PAGE + ); + } + + [ $newNamespace, $search, $replace ] = $instructions; + + if ( $newNamespace === self::IMPOSSIBLE ) { + throw new InvalidPageTitleRename( + 'Trying to move a talk page to a namespace which does not have talk pages', + self::NS_TALK_UNSUPPORTED + ); + } + + $oldTitleText = $title->getText(); + + // Check if the old title matches the string being replaced, if so there is no + // need to run preg_replace. This will happen if the page is being moved from + // one namespace to another. + if ( $oldTitleText === $replace ) { + return Title::makeTitleSafe( $newNamespace, $replace ); + } + + $searchQuoted = preg_quote( $search, '~' ); + $newText = preg_replace( "~^$searchQuoted~", $replace, $oldTitleText, 1 ); + + // If old and new title + namespace are same, the renaming failed. + if ( $oldTitleText === $newText && $newNamespace === $title->getNamespace() ) { + throw new InvalidPageTitleRename( 'Renaming failed', self::RENAME_FAILED ); + } + + $title = Title::makeTitleSafe( $newNamespace, $newText ); + if ( $title === null ) { + throw new InvalidPageTitleRename( 'Invalid target title', self::INVALID_TITLE ); + } + + return $title; + } +} 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>'; + } +} diff --git a/MLEB/Translate/src/PageTranslation/ParserOutput.php b/MLEB/Translate/src/PageTranslation/ParserOutput.php index b9624509..3a61aace 100644 --- a/MLEB/Translate/src/PageTranslation/ParserOutput.php +++ b/MLEB/Translate/src/PageTranslation/ParserOutput.php @@ -59,7 +59,7 @@ class ParserOutput { } /** Returns the source page wikitext used for rendering the page. */ - public function sourcePageTextForRendering( Language $sourceLanguage ) { + public function sourcePageTextForRendering( Language $sourceLanguage ): string { $text = $this->translationPageTemplate(); foreach ( $this->unitMap as $ph => $s ) { @@ -71,7 +71,7 @@ class ParserOutput { } /** Returns the source page with translation unit markers. */ - public function sourcePageTextForSaving() { + public function sourcePageTextForSaving(): string { $text = $this->sourcePageTemplate(); foreach ( $this->unitMap as $ph => $s ) { @@ -81,6 +81,17 @@ class ParserOutput { return $text; } + /** Returns the page text with translation tags and unit placeholders for easy diffs */ + public function sourcePageTemplateForDiffs(): string { + $text = $this->sourcePageTemplate(); + + foreach ( $this->unitMap as $ph => $s ) { + $text = str_replace( $ph, "<!--T:{$s->id}-->", $text ); + } + + return $text; + } + private function assertContainsOnlyInstancesOf( string $expected, string $name, @@ -96,5 +107,3 @@ class ParserOutput { } } } - -class_alias( ParserOutput::class, '\MediaWiki\Extensions\Translate\ParserOutput' ); diff --git a/MLEB/Translate/src/PageTranslation/ParsingFailure.php b/MLEB/Translate/src/PageTranslation/ParsingFailure.php index a3012c7b..3fe615ab 100644 --- a/MLEB/Translate/src/PageTranslation/ParsingFailure.php +++ b/MLEB/Translate/src/PageTranslation/ParsingFailure.php @@ -27,5 +27,3 @@ class ParsingFailure extends RuntimeException { return $this->messageSpec; } } - -class_alias( ParsingFailure::class, '\MediaWiki\Extensions\Translate\ParsingFailure' ); diff --git a/MLEB/Translate/src/PageTranslation/Section.php b/MLEB/Translate/src/PageTranslation/Section.php index b4e30469..9e065ae0 100644 --- a/MLEB/Translate/src/PageTranslation/Section.php +++ b/MLEB/Translate/src/PageTranslation/Section.php @@ -32,5 +32,3 @@ class Section { return $this->open . $this->contents . $this->close; } } - -class_alias( Section::class, '\MediaWiki\Extensions\Translate\Section' ); diff --git a/MLEB/Translate/src/PageTranslation/TestingParsingPlaceholderFactory.php b/MLEB/Translate/src/PageTranslation/TestingParsingPlaceholderFactory.php index 9c914d18..fb0e0b63 100644 --- a/MLEB/Translate/src/PageTranslation/TestingParsingPlaceholderFactory.php +++ b/MLEB/Translate/src/PageTranslation/TestingParsingPlaceholderFactory.php @@ -17,8 +17,3 @@ class TestingParsingPlaceholderFactory extends ParsingPlaceholderFactory { return '<' . $this->i++ . '>'; } } - -class_alias( - TestingParsingPlaceholderFactory::class, - '\MediaWiki\Extensions\Translate\TestingParsingPlaceholderFactory' -); diff --git a/MLEB/Translate/src/PageTranslation/TranslatablePageInsertablesSuggester.php b/MLEB/Translate/src/PageTranslation/TranslatablePageInsertablesSuggester.php index 516a31bd..1c77f32e 100644 --- a/MLEB/Translate/src/PageTranslation/TranslatablePageInsertablesSuggester.php +++ b/MLEB/Translate/src/PageTranslation/TranslatablePageInsertablesSuggester.php @@ -7,24 +7,30 @@ use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\Insertable; use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\MediaWikiInsertablesSuggester; /** - * Special insertables for translatable pages. + * Insertables for translation variables in translatable pages. * @author Niklas Laxström * @license GPL-2.0-or-later * @since 2013.11 */ class TranslatablePageInsertablesSuggester extends MediaWikiInsertablesSuggester { + /** + * Translatable pages allow naming the variables. Almost anything is + * allowed in a variable name, but here we are stricter to avoid too many + * incorrect matches when variable name is followed by non-space characters. + * @internal For use in this namespace only + */ + public const NAME_PATTERN = '\$[\pL\pN_$-]+'; + public function getInsertables( string $text ): array { $insertables = parent::getInsertables( $text ); - // Translatable pages allow naming the variables. Basically anything is - // allowed in a variable name, but here we are stricter to avoid too many - // false positives. $matches = []; - preg_match_all( '/\$([a-zA-Z0-9-_]+)/', $text, $matches, PREG_SET_ORDER ); + $pattern = '/' . self::NAME_PATTERN . '/'; + preg_match_all( $pattern, $text, $matches, PREG_SET_ORDER ); - $new = array_map( function ( $match ) { + $new = array_map( static function ( $match ) { // Numerical ones are already handled by parent - if ( ctype_digit( $match[1] ) ) { + if ( ctype_digit( substr( $match[0], 1 ) ) ) { return null; } @@ -32,13 +38,6 @@ class TranslatablePageInsertablesSuggester extends MediaWikiInsertablesSuggester }, $matches ); $new = array_filter( $new ); - $insertables = array_merge( $insertables, $new ); - - return $insertables; + return array_merge( $insertables, $new ); } } - -class_alias( - TranslatablePageInsertablesSuggester::class, - '\MediaWiki\Extensions\Translate\TranslatablePageInsertablesSuggester' -); diff --git a/MLEB/Translate/src/PageTranslation/TranslatablePageMover.php b/MLEB/Translate/src/PageTranslation/TranslatablePageMover.php index 2729e4f0..8e8de8b9 100644 --- a/MLEB/Translate/src/PageTranslation/TranslatablePageMover.php +++ b/MLEB/Translate/src/PageTranslation/TranslatablePageMover.php @@ -5,8 +5,9 @@ namespace MediaWiki\Extension\Translate\PageTranslation; use AggregateMessageGroup; use JobQueueGroup; -use LinkBatch; +use LogicException; use ManualLogEntry; +use MediaWiki\Cache\LinkBatchFactory; use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot; use MediaWiki\Page\MovePageFactory; use Message; @@ -20,6 +21,7 @@ use Title; use TranslatablePage; use TranslatablePageMoveJob; use TranslateMetadata; +use TranslateUtils; use TranslationsUpdateJob; use Traversable; use User; @@ -32,111 +34,87 @@ use User; */ class TranslatablePageMover { private const LOCK_TIMEOUT = 3600 * 2; + private const FETCH_TRANSLATABLE_SUBPAGES = true; /** @var MovePageFactory */ private $movePageFactory; /** @var int|null */ private $pageMoveLimit; /** @var JobQueueGroup */ private $jobQueue; + /** @var LinkBatchFactory */ + private $linkBatchFactory; /** @var bool */ private $pageMoveLimitEnabled = true; - public function __construct( MovePageFactory $movePageFactory, JobQueueGroup $jobQueue, ?int $pageMoveLimit ) { + public function __construct( + MovePageFactory $movePageFactory, + JobQueueGroup $jobQueue, + LinkBatchFactory $linkBatchFactory, + ?int $pageMoveLimit + ) { $this->movePageFactory = $movePageFactory; $this->jobQueue = $jobQueue; $this->pageMoveLimit = $pageMoveLimit; + $this->linkBatchFactory = $linkBatchFactory; } - /** Makes old title into a new title by replacing $base part of old title with $target. */ - public function newPageTitle( string $base, Title $old, Title $target ): Title { - $search = preg_quote( $base, '~' ); - - if ( $old->inNamespace( NS_TRANSLATIONS ) ) { - $new = $old->getText(); - $new = preg_replace( "~^$search~", $target->getPrefixedText(), $new, 1 ); - - return Title::makeTitleSafe( NS_TRANSLATIONS, $new ); - } else { - $new = $old->getPrefixedText(); - $new = preg_replace( "~^$search~", $target->getPrefixedText(), $new, 1 ); - - return Title::newFromText( $new ); - } - } - - /** @return SplObjectStorage Title => Status */ - public function checkMoveBlockers( + public function getPageMoveCollection( Title $source, ?Title $target, User $user, string $reason, - bool $moveSubPages - ): SplObjectStorage { + bool $moveSubPages, + bool $moveTalkPages + ): PageMoveCollection { $blockers = new SplObjectStorage(); - $page = TranslatablePage::newFromTitle( $source ); - if ( !$target ) { $blockers[$source] = Status::newFatal( 'pt-movepage-block-base-invalid' ); - return $blockers; + throw new ImpossiblePageMove( $blockers ); } if ( $target->inNamespaces( NS_MEDIAWIKI, NS_TRANSLATIONS ) ) { $blockers[$source] = Status::newFatal( 'immobile-target-namespace', $target->getNsText() ); - return $blockers; + throw new ImpossiblePageMove( $blockers ); } - if ( $target->exists() ) { - $blockers[$source] = Status::newFatal( - 'pt-movepage-block-base-exists', $target->getPrefixedText() - ); - } else { - $movePage = $this->movePageFactory->newMovePage( $source, $target ); - $status = $movePage->isValidMove(); - $status->merge( $movePage->checkPermissions( $user, $reason ) ); - if ( !$status->isOK() ) { - $blockers[$source] = $status; - } + $movePage = $this->movePageFactory->newMovePage( $source, $target ); + $status = $movePage->isValidMove(); + $status->merge( $movePage->checkPermissions( $user, $reason ) ); + if ( !$status->isOK() ) { + $blockers[$source] = $status; } // Don't spam the same errors for all pages if base page fails if ( count( $blockers ) ) { - return $blockers; + throw new ImpossiblePageMove( $blockers ); } - // Collect all the old and new titles for checks - $titles = []; - $base = $source->getPrefixedText(); - $pages = $page->getTranslationPages(); - foreach ( $pages as $old ) { - $titles['tp'][] = [ $old, $this->newPageTitle( $base, $old, $target ) ]; - } - - $subpages = $moveSubPages ? $this->getNormalSubpages( $page ) : []; - foreach ( $subpages as $old ) { - $titles['subpage'][] = [ $old, $this->newPageTitle( $base, $old, $target ) ]; - } + $pageCollection = $this->getPagesToMove( + $source, $target, $moveSubPages, self::FETCH_TRANSLATABLE_SUBPAGES, $moveTalkPages + ); - $pages = $page->getTranslationUnitPages( 'all' ); - foreach ( $pages as $old ) { - $titles['section'][] = [ $old, $this->newPageTitle( $base, $old, $target ) ]; - } + // Collect all the old and new titles for checks + $titles = [ + 'tp' => $pageCollection->getTranslationPagesPair(), + 'subpage' => $pageCollection->getSubpagesPair(), + 'section' => $pageCollection->getUnitPagesPair() + ]; // Check that all new titles are valid and count them. Add 1 for source page. $moveCount = 1; - $lb = new LinkBatch(); + $lb = $this->linkBatchFactory->newLinkBatch(); foreach ( $titles as $type => $list ) { $moveCount += count( $list ); // Give grep a chance to find the usages: // pt-movepage-block-tp-invalid, pt-movepage-block-section-invalid, // pt-movepage-block-subpage-invalid foreach ( $list as $pair ) { - [ $old, $new ] = $pair; + $old = $pair->getOldTitle(); + $new = $pair->getNewTitle(); + if ( $new === null ) { - $blockers[$old] = Status::newFatal( - "pt-movepage-block-$type-invalid", - $old->getPrefixedText() - ); + $blockers[$old] = $this->getRenameMoveBlocker( $old, $type, $pair->getRenameErrorCode() ); continue; } $lb->addObj( $old ); @@ -153,48 +131,44 @@ class TranslatablePageMover { } } + // Stop further validation if there are blockers already. if ( count( $blockers ) ) { - return $blockers; + throw new ImpossiblePageMove( $blockers ); } // Check that there are no move blockers - $lb->execute(); + $lb->setCaller( __METHOD__ )->execute(); foreach ( $titles as $type => $list ) { - // Give grep a chance to find the usages: - // pt-movepage-block-tp-exists, pt-movepage-block-section-exists, - // pt-movepage-block-subpage-exists foreach ( $list as $pair ) { - list( $old, $new ) = $pair; - if ( $new->exists() ) { - $blockers[$old] = Status::newFatal( - "pt-movepage-block-$type-exists", - $old->getPrefixedText(), - $new->getPrefixedText() - ); - } else { - /* This method has terrible performance: - * - 2 queries by core - * - 3 queries by lqt - * - and no obvious way to preload the data! */ - $movePage = $this->movePageFactory->newMovePage( $old, $target ); - $status = $movePage->isValidMove(); - // Do not check for permissions here, as these pages are not editable/movable - // in regular use - if ( !$status->isOK() ) { - $blockers[$old] = $status; - } - - /* Because of the poor performance, check only one of the possibly thousands - * of section pages and assume rest are fine. This assumes section pages are - * listed last in the array. */ - if ( $type === 'section' ) { - break; - } + $old = $pair->getOldTitle(); + $new = $pair->getNewTitle(); + + /* This method has terrible performance: + * - 2 queries by core + * - 3 queries by lqt + * - and no obvious way to preload the data! */ + $movePage = $this->movePageFactory->newMovePage( $old, $new ); + $status = $movePage->isValidMove(); + // Do not check for permissions here, as these pages are not editable/movable + // in regular use + if ( !$status->isOK() ) { + $blockers[$old] = $status; + } + + /* Because of the poor performance, check only one of the possibly thousands + * of section pages and assume rest are fine. This assumes section pages are + * listed last in the array. */ + if ( $type === 'section' ) { + break; } } } - return $blockers; + if ( count( $blockers ) ) { + throw new ImpossiblePageMove( $blockers ); + } + + return $pageCollection; } public function moveAsynchronously( @@ -202,13 +176,17 @@ class TranslatablePageMover { Title $target, bool $moveSubPages, User $user, - string $summary + string $summary, + bool $moveTalkPages ): void { - $pageMoves = $this->getPagesToMove( $source, $target, $moveSubPages ); + $pageCollection = $this->getPagesToMove( + $source, $target, $moveSubPages, !self::FETCH_TRANSLATABLE_SUBPAGES, $moveTalkPages + ); + $pagesToMove = $pageCollection->getListOfPages(); - $job = TranslatablePageMoveJob::newJob( $source, $target, $pageMoves, $summary, $user ); - $this->lock( array_keys( $pageMoves ) ); - $this->lock( array_values( $pageMoves ) ); + $job = TranslatablePageMoveJob::newJob( $source, $target, $pagesToMove, $summary, $user ); + $this->lock( array_keys( $pagesToMove ) ); + $this->lock( array_values( $pagesToMove ) ); $this->jobQueue->push( $job ); } @@ -243,6 +221,8 @@ class TranslatablePageMover { $this->moveMetadata( $sourcePage->getMessageGroupId(), $targetPage->getMessageGroupId() ); + TranslatablePage::clearSourcePageCache(); + // Re-render the pages to get everything in sync MessageGroups::singleton()->recache(); // Update message index now so that, when after this job the MoveTranslationUnits hook @@ -253,64 +233,111 @@ class TranslatablePageMover { $this->jobQueue->push( $job ); } - /** @return Title[] */ - public function getNormalSubpages( TranslatablePage $page ): array { - return array_filter( - $this->getSubpages( $page ), - function ( $page ) { - return !( - TranslatablePage::isTranslationPage( $page ) || - TranslatablePage::isSourcePage( $page ) - ); - } - ); + public function disablePageMoveLimit(): void { + $this->pageMoveLimitEnabled = false; } - /** @return Title[] */ - public function getTranslatableSubpages( TranslatablePage $page ): array { - return array_filter( - $this->getSubpages( $page ), - function ( $page ) { - return TranslatablePage::isSourcePage( $page ); - } - ); + public function enablePageMoveLimit(): void { + $this->pageMoveLimitEnabled = true; } - /** @return string[] */ - public function getPagesToMove( Title $source, Title $target, bool $moveSubPages ): array { + private function getPagesToMove( + Title $source, + Title $target, + bool $moveSubPages, + bool $fetchTranslatableSubpages, + bool $moveTalkPages + ): PageMoveCollection { $page = TranslatablePage::newFromTitle( $source ); - $base = $source->getPrefixedText(); - - $moves = []; - $moves[$base] = $target->getPrefixedText(); + $translatableMovePage = new PageMoveOperation( $source, $target ); + $pageTitleRenamer = new PageTitleRenamer( $source, $target ); + $translationPageList = []; foreach ( $page->getTranslationPages() as $from ) { - $to = $this->newPageTitle( $base, $from, $target ); - $moves[$from->getPrefixedText()] = $to->getPrefixedText(); + $translationPageList[] = $this->createPageMoveOperation( $pageTitleRenamer, $from ); } + $translationUnitPageList = []; foreach ( $page->getTranslationUnitPages( 'all' ) as $from ) { - $to = $this->newPageTitle( $base, $from, $target ); - $moves[$from->getPrefixedText()] = $to->getPrefixedText(); + $translationUnitPageList[] = $this->createPageMoveOperation( $pageTitleRenamer, $from ); + } + + $subpageList = []; + if ( $moveSubPages && TranslateUtils::allowsSubpages( $source ) ) { + $currentSubpages = $this->getNormalSubpages( $page ); + foreach ( $currentSubpages as $from ) { + $subpageList[] = $this->createPageMoveOperation( $pageTitleRenamer, $from ); + } } - if ( $moveSubPages ) { - $subpages = $this->getNormalSubpages( $page ); - foreach ( $subpages as $from ) { - $to = $this->newPageTitle( $base, $from, $target ); - $moves[$from->getPrefixedText()] = $to->getPrefixedText(); + $translatableTalkpageList = []; + // If the source page is a talk page itself, no point looking for more talk pages + if ( $moveTalkPages && !$source->isTalkPage() ) { + $possiblePagesToBeMoved = array_merge( + [ $translatableMovePage ], + $translationPageList, + $translationUnitPageList, + $subpageList + ); + + $talkPages = $this->getTalkPagesForMove( $possiblePagesToBeMoved ); + foreach ( $possiblePagesToBeMoved as $index => $pageOperation ) { + $currentTalkPage = $talkPages[$index] ?? null; + if ( $currentTalkPage === null ) { + continue; + } + + // If the talk page is translatable, we do not move it, and inform the user + // that this needs to be moved separately. + if ( TranslatablePage::isSourcePage( $currentTalkPage ) ) { + $translatableTalkpageList[] = $currentTalkPage; + continue; + } + + $pageOperation->setTalkpage( + $currentTalkPage, $pageTitleRenamer->getNewTitle( $currentTalkPage ) + ); } } - return $moves; + $relatedTranslatablePageList = $translatableTalkpageList; + if ( $fetchTranslatableSubpages ) { + $relatedTranslatablePageList = array_merge( + $relatedTranslatablePageList, + $this->getTranslatableSubpages( TranslatablePage::newFromTitle( $source ) ) + ); + } + + return new PageMoveCollection( + $translatableMovePage, + $translationPageList, + $translationUnitPageList, + $subpageList, + $relatedTranslatablePageList + ); } - public function disablePageMoveLimit(): void { - $this->pageMoveLimitEnabled = false; + /** @return Title[] */ + private function getNormalSubpages( TranslatablePage $page ): array { + return array_filter( + $this->getSubpages( $page ), + static function ( $page ) { + return !( + TranslatablePage::isTranslationPage( $page ) || + TranslatablePage::isSourcePage( $page ) + ); + } + ); } - public function enablePageMoveLimit(): void { - $this->pageMoveLimitEnabled = true; + /** @return Title[] */ + private function getTranslatableSubpages( TranslatablePage $page ): array { + return array_filter( + $this->getSubpages( $page ), + static function ( $page ) { + return TranslatablePage::isSourcePage( $page ); + } + ); } /** @@ -411,7 +438,7 @@ class TranslatablePageMover { } private function moveMetadata( string $oldGroupId, string $newGroupId ): void { - TranslateMetadata::preloadGroups( [ $oldGroupId, $newGroupId ] ); + TranslateMetadata::preloadGroups( [ $oldGroupId, $newGroupId ], __METHOD__ ); foreach ( TranslatablePage::METADATA_KEYS as $type ) { $value = TranslateMetadata::get( $oldGroupId, $type ); if ( $value !== false ) { @@ -422,7 +449,7 @@ class TranslatablePageMover { // Make the changes in aggregate groups metadata, if present in any of them. $aggregateGroups = MessageGroups::getGroupsByType( AggregateMessageGroup::class ); - TranslateMetadata::preloadGroups( array_keys( $aggregateGroups ) ); + TranslateMetadata::preloadGroups( array_keys( $aggregateGroups ), __METHOD__ ); foreach ( $aggregateGroups as $id => $group ) { $subgroups = TranslateMetadata::get( $id, 'subgroups' ); @@ -443,5 +470,73 @@ class TranslatablePageMover { ); } } + + // Move discouraged status + $priority = MessageGroups::getPriority( $oldGroupId ); + if ( $priority !== '' ) { + MessageGroups::setPriority( $newGroupId, $priority ); + MessageGroups::setPriority( $oldGroupId, '' ); + } + } + + /** + * To identify the talk pages, we first gather the possible talk pages into + * and then check that they exist. Title::exists perform a database check so + * we gather them into LinkBatch to reduce the performance impact. + * @param PageMoveOperation[] $pageMoveOperations + * @return Title[] + */ + private function getTalkPagesForMove( array $pageMoveOperations ): array { + $lb = $this->linkBatchFactory->newLinkBatch(); + $talkPageList = []; + + foreach ( $pageMoveOperations as $pageOperation ) { + $talkPage = $pageOperation->getOldTitle()->getTalkPageIfDefined(); + $talkPageList[] = $talkPage; + if ( $talkPage ) { + $lb->addObj( $talkPage ); + } + } + + $lb->setCaller( __METHOD__ )->execute(); + foreach ( $talkPageList as $index => $talkPage ) { + if ( !$talkPage || !$talkPage->exists() ) { + $talkPageList[$index] = null; + } + } + + return $talkPageList; + } + + private function createPageMoveOperation( PageTitleRenamer $renamer, Title $from ): PageMoveOperation { + try { + $to = $renamer->getNewTitle( $from ); + $operation = new PageMoveOperation( $from, $to ); + } catch ( InvalidPageTitleRename $e ) { + $operation = new PageMoveOperation( $from, null, $e ); + } + + return $operation; + } + + private function getRenameMoveBlocker( Title $old, string $pageType, int $renameError ): Status { + if ( $renameError === PageTitleRenamer::NO_ERROR ) { + throw new LogicException( + 'Trying to fetch MoveBlocker when there was no error during rename. Title: ' . + $old->getPrefixedText() . ', page type: ' . $pageType + ); + } + + if ( $renameError === PageTitleRenamer::UNKNOWN_PAGE ) { + $status = Status::newFatal( 'pt-movepage-block-unknown-page', $old->getPrefixedText() ); + } elseif ( $renameError === PageTitleRenamer::NS_TALK_UNSUPPORTED ) { + $status = Status::newFatal( 'pt-movepage-block-ns-talk-unsupported', $old->getPrefixedText() ); + } elseif ( $renameError === PageTitleRenamer::RENAME_FAILED ) { + $status = Status::newFatal( 'pt-movepage-block-rename-failed', $old->getPrefixedText() ); + } else { + return Status::newFatal( "pt-movepage-block-$pageType-invalid", $old->getPrefixedText() ); + } + + return $status; } } diff --git a/MLEB/Translate/src/PageTranslation/TranslatablePageParser.php b/MLEB/Translate/src/PageTranslation/TranslatablePageParser.php index 78ffb4ae..ff52eb3b 100644 --- a/MLEB/Translate/src/PageTranslation/TranslatablePageParser.php +++ b/MLEB/Translate/src/PageTranslation/TranslatablePageParser.php @@ -39,9 +39,7 @@ class TranslatablePageParser { $text = preg_replace( "~(^=.*=) <!--T:[^$ic]+-->$~um", '\1', $text ); $text = preg_replace( "~<!--T:[^$ic]+-->[\n ]?~um", '', $text ); // Remove variables - $unit = new TranslationUnit(); - $unit->id = 'XXX'; - $unit->text = $text; + $unit = new TranslationUnit( $text ); $text = $unit->getTextForTrans(); $text = $this->unarmourNowiki( $nowiki, $text ); @@ -168,11 +166,11 @@ class TranslatablePageParser { ); } - $section = new TranslationUnit(); + // If no id given in the source, default to a new section id + $id = TranslationUnit::NEW_UNIT_ID; if ( $count === 1 ) { foreach ( $matches as $match ) { [ /*full*/, $id ] = $match; - $section->id = $id; // Currently handle only these two standard places. // Is this too strict? @@ -193,14 +191,9 @@ class TranslatablePageParser { ); } } - } else { - // New section - $section->id = -1; } - $section->text = $content; - - return $section; + return new TranslationUnit( $content, $id ); } /** @internal */ @@ -221,5 +214,3 @@ class TranslatablePageParser { return strtr( $text, $holders ); } } - -class_alias( TranslatablePageParser::class, '\MediaWiki\Extensions\Translate\TranslatablePageParser' ); diff --git a/MLEB/Translate/src/PageTranslation/TranslationPage.php b/MLEB/Translate/src/PageTranslation/TranslationPage.php index 5efb7a47..39c6336f 100644 --- a/MLEB/Translate/src/PageTranslation/TranslationPage.php +++ b/MLEB/Translate/src/PageTranslation/TranslationPage.php @@ -3,8 +3,11 @@ declare( strict_types = 1 ); namespace MediaWiki\Extension\Translate\PageTranslation; +use Content; +use ContentHandler; use Language; use MessageCollection; +use Title; use TMessage; use WikiPageMessageGroup; @@ -30,8 +33,8 @@ class TranslationPage { private $showOutdated; /** @var bool */ private $wrapUntranslated; - /** @var string */ - private $prefix; + /** @var Title */ + private $sourcePageTitle; public function __construct( ParserOutput $output, @@ -40,7 +43,7 @@ class TranslationPage { Language $sourceLanguage, bool $showOutdated, bool $wrapUntranslated, - string $prefix + Title $sourcePageTitle ) { $this->output = $output; $this->group = $group; @@ -48,7 +51,7 @@ class TranslationPage { $this->sourceLanguage = $sourceLanguage; $this->showOutdated = $showOutdated; $this->wrapUntranslated = $wrapUntranslated; - $this->prefix = $prefix; + $this->sourcePageTitle = $sourcePageTitle; } /** Generate translation page source using default options. */ @@ -59,6 +62,13 @@ class TranslationPage { return $this->generateSourceFromTranslations( $messages ); } + /** @since 2021.07 */ + public function getPageContent(): Content { + $text = $this->generateSource(); + $model = $this->sourcePageTitle->getContentModel(); + return ContentHandler::makeContent( $text, null, $model ); + } + public function getMessageCollection(): MessageCollection { return $this->group->initCollection( $this->targetLanguage->getCode() ); } @@ -75,8 +85,9 @@ class TranslationPage { /** @return TMessage[] */ public function extractMessages( MessageCollection $collection ): array { $messages = []; + $prefix = $this->sourcePageTitle->getPrefixedDBkey() . '/'; foreach ( $this->output->units() as $unit ) { - $messages[$unit->id] = $collection[$this->prefix . $unit->id] ?? null; + $messages[$unit->id] = $collection[$prefix . $unit->id] ?? null; } return $messages; @@ -100,5 +111,3 @@ class TranslationPage { return strtr( $template, $replacements ); } } - -class_alias( TranslationPage::class, '\MediaWiki\Extensions\Translate\TranslationPage' ); diff --git a/MLEB/Translate/src/PageTranslation/TranslationUnit.php b/MLEB/Translate/src/PageTranslation/TranslationUnit.php index 4006f458..b70682d0 100644 --- a/MLEB/Translate/src/PageTranslation/TranslationUnit.php +++ b/MLEB/Translate/src/PageTranslation/TranslationUnit.php @@ -17,10 +17,9 @@ use const PREG_SET_ORDER; */ class TranslationUnit { public const UNIT_MARKER_INVALID_CHARS = "_/\n<>"; + public const NEW_UNIT_ID = '-1'; /** @var string Unit name */ public $id; - /** @var ?string New name of the unit, that will be saved to database. */ - public $name = null; /** @var string Unit text. */ public $text; /** @var string Is this new, existing, changed or deleted unit. */ @@ -37,7 +36,19 @@ class TranslationUnit { /** @var int Version number for the serialization. */ private $version = 1; /** @var string[] List of properties to serialize. */ - private static $properties = [ 'version', 'id', 'name', 'text', 'type', 'oldText', 'inline' ]; + private static $properties = [ 'version', 'id', 'text', 'type', 'oldText', 'inline' ]; + + public function __construct( + string $text, + string $id = self::NEW_UNIT_ID, + string $type = 'new', + string $oldText = null + ) { + $this->text = $text; + $this->id = $id; + $this->type = $type; + $this->oldText = $oldText; + } public function setIsInline( bool $value ): void { $this->inline = $value; @@ -82,8 +93,8 @@ class TranslationUnit { /** Returns the unit text with updated or added unit marker */ public function getMarkedText(): string { - $id = $this->name ?? $this->id; - $header = "<!--T:{$id}-->"; + $id = $this->id; + $header = "<!--T:$id-->"; $re = '~^(=+.*?=+\s*?$)~m'; $rep = "\\1 $header"; $count = 0; @@ -153,7 +164,8 @@ REGEXP; } public static function unserializeFromArray( array $data ): self { - $unit = new self(); + // Give dummy default text, will be overridden + $unit = new self( '' ); foreach ( self::$properties as $index => $property ) { $unit->$property = $data[$index]; } @@ -209,4 +221,36 @@ REGEXP; return $content; } + + /** @return TranslationUnitIssue[] */ + public function getIssues(): array { + $issues = $usedNames = []; + foreach ( $this->getVariables() as $variable ) { + $name = $variable->getName(); + $pattern = '/^' . TranslatablePageInsertablesSuggester::NAME_PATTERN . '$/u'; + if ( !preg_match( $pattern, $name ) ) { + // Key by name to avoid multiple issues of the same name + $issues[$name] = new TranslationUnitIssue( + TranslationUnitIssue::WARNING, + 'tpt-validation-not-insertable', + [ wfEscapeWikiText( $name ) ] + ); + } + + $usedNames[ $name ][] = $variable->getValue(); + } + + foreach ( $usedNames as $name => $contents ) { + $uniqueValueCount = count( array_unique( $contents ) ); + if ( $uniqueValueCount > 1 ) { + $issues[] = new TranslationUnitIssue( + TranslationUnitIssue::ERROR, + 'tpt-validation-name-reuse', + [ wfEscapeWikiText( $name ) ] + ); + } + } + + return array_values( $issues ); + } } diff --git a/MLEB/Translate/src/PageTranslation/TranslationUnitIssue.php b/MLEB/Translate/src/PageTranslation/TranslationUnitIssue.php new file mode 100644 index 00000000..65f9508b --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/TranslationUnitIssue.php @@ -0,0 +1,46 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use InvalidArgumentException; +use MediaWiki\Extension\Translate\Validation\ValidationIssue; +use MessageSpecifier; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2021.05 + * @see ValidationIssue (similar, but different use case) + */ +class TranslationUnitIssue implements MessageSpecifier { + public const ERROR = 'error'; + public const WARNING = 'warning'; + /** @var string self::ERROR|self::WARNING */ + private $severity; + /** @var string */ + private $messageKey; + /** @var array */ + private $messageParams; + + public function __construct( string $severity, string $messageKey, array $messageParams = [] ) { + if ( !in_array( $severity, [ self::ERROR, self::WARNING ] ) ) { + throw new InvalidArgumentException( 'Invalid value for severity: ' . $severity ); + } + $this->severity = $severity; + $this->messageKey = $messageKey; + $this->messageParams = $messageParams; + } + + public function getSeverity(): string { + return $this->severity; + } + + public function getKey(): string { + return $this->messageKey; + } + + public function getParams(): array { + return $this->messageParams; + } +} diff --git a/MLEB/Translate/src/PageTranslation/TranslationUnitReader.php b/MLEB/Translate/src/PageTranslation/TranslationUnitReader.php new file mode 100644 index 00000000..fbaf462a --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/TranslationUnitReader.php @@ -0,0 +1,17 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +/** + * @license GPL-2.0-or-later + * @author Niklas Laxström + * @since 2021.05 + */ +interface TranslationUnitReader { + /** @return TranslationUnit[] */ + public function getUnits(): array; + + /** @return string[] */ + public function getNames(): array; +} diff --git a/MLEB/Translate/src/PageTranslation/TranslationUnitStore.php b/MLEB/Translate/src/PageTranslation/TranslationUnitStore.php new file mode 100644 index 00000000..878d1ef0 --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/TranslationUnitStore.php @@ -0,0 +1,58 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Wikimedia\Rdbms\IDatabase; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2021.05 + */ +class TranslationUnitStore implements TranslationUnitReader { + private const TABLE = 'translate_sections'; + /** @var IDatabase */ + private $db; + /** @var int */ + private $pageId; + + public function __construct( IDatabase $db, int $pageId ) { + $this->db = $db; + $this->pageId = $pageId; + } + + public function getUnits(): array { + $res = $this->db->select( + self::TABLE, + [ 'trs_key', 'trs_text' ], + [ 'trs_page' => $this->pageId ], + __METHOD__ + ); + + $units = []; + foreach ( $res as $row ) { + $units[$row->trs_key] = new TranslationUnit( $row->trs_text, $row->trs_key ); + } + + return $units; + } + + /** @return string[] */ + public function getNames(): array { + return $this->db->selectFieldValues( + self::TABLE, + 'trs_key', + [ 'trs_page' => $this->pageId ], + __METHOD__ + ); + } + + public function delete(): void { + $this->db->delete( + self::TABLE, + [ 'trs_page' => $this->pageId ], + __METHOD__ + ); + } +} diff --git a/MLEB/Translate/src/PageTranslation/TranslationUnitStoreFactory.php b/MLEB/Translate/src/PageTranslation/TranslationUnitStoreFactory.php new file mode 100644 index 00000000..3c2cefb2 --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/TranslationUnitStoreFactory.php @@ -0,0 +1,42 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use LogicException; +use Title; +use Wikimedia\Rdbms\ILoadBalancer; +use const DB_PRIMARY; +use const DB_REPLICA; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2021.05 + */ +class TranslationUnitStoreFactory { + /** @var ILoadBalancer */ + private $lb; + + public function __construct( ILoadBalancer $lb ) { + $this->lb = $lb; + } + + public function getReader( Title $page ): TranslationUnitReader { + $pageId = $page->getArticleID(); + if ( $pageId === 0 ) { + throw new LogicException( 'Page must exist' ); + } + + return new TranslationUnitStore( $this->lb->getConnectionRef( DB_REPLICA ), $pageId ); + } + + public function getWriter( Title $page ): TranslationUnitStore { + $pageId = $page->getArticleID(); + if ( $pageId === 0 ) { + throw new LogicException( 'Page must exist' ); + } + + return new TranslationUnitStore( $this->lb->getConnectionRef( DB_PRIMARY ), $pageId ); + } +} |