summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'MLEB/Translate/src/PageTranslation')
-rw-r--r--MLEB/Translate/src/PageTranslation/ImpossiblePageMove.php27
-rw-r--r--MLEB/Translate/src/PageTranslation/InvalidPageTitleRename.php15
-rw-r--r--MLEB/Translate/src/PageTranslation/MoveTranslatablePageMaintenanceScript.php160
-rw-r--r--MLEB/Translate/src/PageTranslation/MoveTranslatablePageSpecialPage.php402
-rw-r--r--MLEB/Translate/src/PageTranslation/PageMoveCollection.php144
-rw-r--r--MLEB/Translate/src/PageTranslation/PageMoveOperation.php61
-rw-r--r--MLEB/Translate/src/PageTranslation/PageTitleRenamer.php105
-rw-r--r--MLEB/Translate/src/PageTranslation/PageTranslationSpecialPage.php1271
-rw-r--r--MLEB/Translate/src/PageTranslation/ParserOutput.php17
-rw-r--r--MLEB/Translate/src/PageTranslation/ParsingFailure.php2
-rw-r--r--MLEB/Translate/src/PageTranslation/Section.php2
-rw-r--r--MLEB/Translate/src/PageTranslation/TestingParsingPlaceholderFactory.php5
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslatablePageInsertablesSuggester.php29
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslatablePageMover.php373
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslatablePageParser.php17
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationPage.php23
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationUnit.php56
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationUnitIssue.php46
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationUnitReader.php17
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationUnitStore.php58
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationUnitStoreFactory.php42
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 );
+ }
+}